apiReport

tcmts.cli.releases.apiReport · #jkdwoefl · term · runs on the server

uses tcmts.cli.semver_checks.checkPairs, which uses node:path

What changed in a package's API from one state of it to another, lint by lint.

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { diffNamespaces, keyPath, stateOf } from "#codebase/tcmts/cli/diff";
import { summaryOf } from "#codebase/tcmts/cli/releases";
import type { ApiReport, PackageState } from "#codebase/tcmts/cli/releases";
import { checkPairs, levelOf } from "#codebase/tcmts/cli/semver_checks";
import type { Finding } from "#codebase/tcmts/cli/semver_checks";

export function apiReport(cb: Codebase, before: PackageState, after: PackageState): ApiReport {
  const def = (h: string) => cb.store.getDef(h)!;
  const api = (s: PackageState) => stateOf(s.rows.filter((r) => s.exported.has(r.path)));
  const irOf = (h: string) => def(h).ir;
  const d = diffNamespaces(api(before), api(after), irOf);
  // re-hashed only because something it uses changed, wherever that is (another package, say): judged on the
  // whole namespace, since the API's slice of it doesn't have the cause
  const following = new Set(
    diffNamespaces(stateOf(before.rows), stateOf(after.rows), irOf).propagated.map((c) =>
      keyPath(c.path),
    ),
  );
  const findings: Finding[] = [];
  const summaries = new Map<string, string>();
  const signatures = new Map<string, string>();
  for (const r of d.removed) findings.push({ lint: "definition_removed", path: keyPath(r.path) });
  for (const r of d.renamed)
    findings.push({ lint: "definition_renamed", path: keyPath(r.from), detail: keyPath(r.to) });
  for (const a of [...d.added, ...d.aliased]) {
    findings.push({ lint: "definition_added", path: keyPath(a.path) });
    const n = def(a.hash);
    if (n.kind !== "type") signatures.set(keyPath(a.path), n.signature);
    const s = summaryOf(n.doc);
    if (s) summaries.set(keyPath(a.path), s);
  }
  for (const [p, sub] of after.exported) {
    const was = before.exported.get(p);
    if (was !== undefined && was !== sub)
      findings.push({ lint: "export_moved", path: p, detail: `${was} → ${sub}` });
  }
  const [binsBefore, binsAfter] = [before.pkg.manifest.bin, after.pkg.manifest.bin];
  for (const cmd of Object.keys(binsBefore))
    if (!(cmd in binsAfter)) findings.push({ lint: "bin_removed", path: cmd });
  for (const cmd of Object.keys(binsAfter))
    if (!(cmd in binsBefore)) findings.push({ lint: "bin_added", path: cmd });
  const checked = checkPairs(
    cb,
    [...d.edited, ...d.propagated].map((c) => ({
      path: keyPath(c.path),
      old: def(c.old),
      new: def(c.new),
    })),
  );
  const followed: string[] = [];
  for (const f of checked) {
    // a definition whose code didn't change, only what it uses: its API change (if any) is reported, its
    // "implementation change" is the dependency's
    if (f.lint === "implementation_changed" && following.has(f.path)) followed.push(f.path);
    else findings.push(f);
  }
  for (const c of d.edited) {
    const s = summaryOf(def(c.new).doc);
    if (s) summaries.set(keyPath(c.path), s);
  }
  // `a: Point → Point`: the change is Point's; said once, where Point is (unless Point isn't in the API)
  const reported = new Set(findings.map((f) => f.path.slice(f.path.lastIndexOf(".") + 1)));
  const echo = (f: Finding) => {
    const m = /^(?:[\w$]+: )?(.+), whose definition changed$/.exec(f.detail ?? "");
    return !!m && [...m[1]!.matchAll(/[A-Za-z_$][\w$]*/g)].some((t) => reported.has(t[0]));
  };
  const kept = findings.filter((f) => !echo(f));
  // breaking changes first, then additions, then the rest; by name within each
  const rank = { major: 0, minor: 1, patch: 2 };
  kept.sort(
    (a, b) =>
      rank[levelOf(a)] - rank[levelOf(b)] || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
  );
  return { findings: kept, following: followed, summaries, signatures };
}

uses

used by