diff --git a/.changeset/code-graph-published-api-diff.md b/.changeset/code-graph-published-api-diff.md new file mode 100644 index 00000000..67f910be --- /dev/null +++ b/.changeset/code-graph-published-api-diff.md @@ -0,0 +1,12 @@ +--- +"@demlik/code-graph": minor +--- + +New: diff a package's published API against a base commit. `code-graph --api +--api-base ` and `diffPublishedApi(root, map, base)` from `@demlik/code-graph/api` list, per +export subpath, the names added, removed and changed since ``, with before/after declaration +text and the subpath's tier. A change to a private type shows as a change to the published name +that uses it. The base commit's tree is read from git's objects into a temp folder outside the +checkout, so the checkout's files, index, branch and stash are left as they were; the "after" side +is the working tree as it is. A rev that names no commit exits 2. Without `--api-base`, nothing +changes. diff --git a/.changeset/code-graph-published-api-ratchet.md b/.changeset/code-graph-published-api-ratchet.md new file mode 100644 index 00000000..73d99d02 --- /dev/null +++ b/.changeset/code-graph-published-api-ratchet.md @@ -0,0 +1,15 @@ +--- +"@demlik/code-graph": minor +--- + +New: gate a PR on its published-API diff. `code-graph --api --api-base +--api-policy ` checks every added, removed and changed name against the changesets added +since ``. The policy is a JSON file the caller writes: per tier and change kind, the least +bump (`none`, `patch`, `minor`, `major`) and whether a callout is owed, plus the callout's marker +text. code-graph ships no policy of its own, and a changed name whose tier the policy gives no row +and no `default` exits 2 instead of passing. The changesets that count are the `.changeset/*.md` +files the base commit lacks whose frontmatter names the package; the highest of their bumps is the +bump found. It exits 0 on a pass and 1 on a miss, printing one block per name that misses with its +subpath, tier, change kind, the bump needed and found, and the before/after text; `--json` prints +the verdict. `readChangesetsSince`, `ratchetApiDiff` and `BumpPolicySchema` from +`@demlik/code-graph/api` are the library side. Without `--api-policy`, nothing changes. diff --git a/.changeset/code-graph-published-api-view.md b/.changeset/code-graph-published-api-view.md new file mode 100644 index 00000000..ddd407e3 --- /dev/null +++ b/.changeset/code-graph-published-api-view.md @@ -0,0 +1,14 @@ +--- +"@demlik/code-graph": minor +--- + +New opt-in mode: the published-API view. `code-graph --api ` and +`readPublishedApi(root, map)` from the new `@demlik/code-graph/api` subpath list, per export +subpath, every name the package publishes with its declaration text as a consumer's types see it. +You write the map (`{ "": { "entry": "", "tier"?: "" } }`); code-graph +reads no export map or build config. It emits the package's declarations with the pinned tsgo into +a temp folder outside the checkout, so an inferred return type shows in a name's text, and each name +carries the text of the unpublished declarations it references, so a change to a private type shows +on the published name that uses it. The output is sorted JSON, the same bytes for the same commit. +Without `--api`, every other output is unchanged. `@demlik/code-graph/resolve` also exports +`moduleSymbolOf`, the file-to-module-symbol step the view shares with `resolveModuleExport`. diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 32bbb633..9dd3c71d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,8 +18,13 @@ jobs: runs-on: ubuntu-latest steps: + # Depth 2, not the default 1: on a pull request the checkout is the merge of + # the PR into its base, and the "Published API" step below diffs against + # that merge's first parent — the base commit — so it has to be fetched. - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 2 - uses: pnpm/action-setup@v4 name: Install pnpm @@ -88,6 +93,20 @@ jobs: - name: Check every export carries a tier stamp run: pnpm --filter @demlik/tea exec node scripts/check-export-stamps.mjs + # The step above checks that every subpath HAS a tier; this one holds a PR + # to what the tier promises. It diffs every name tea publishes against the + # PR's base commit and fails, naming the name, its subpath, its tier and + # the before/after text, when a change lacks the changeset MAINTAINING.md's + # semver policy asks for. A PR that changes no published name passes with + # no changeset. `HEAD^1` is the base commit the checkout merged the PR + # into, so the diff holds exactly what the PR adds. Pull requests only: a + # push has no base to diff against. + - name: Published API changes carry the changeset their tier asks for + if: github.event_name == 'pull_request' + env: + TEA_API_BASE: HEAD^1 + run: pnpm --filter @demlik/tea run api:ratchet + - name: Run tests run: pnpm test diff --git a/packages/code-graph/SPEC.md b/packages/code-graph/SPEC.md index 72c7fb03..18447b83 100644 --- a/packages/code-graph/SPEC.md +++ b/packages/code-graph/SPEC.md @@ -25,6 +25,8 @@ When sent to refactor folder `X`, the agent's first moves are Bash calls, not Re | Full graph (rarely — large) | `code-graph X --graph` | | Comment census — how much comment, of what kind, where | `code-graph X --comments` | | Comment ratio ratchet (a gate) | `code-graph . --comments --ci` | +| What a package publishes, per export subpath (§13) | `code-graph --api ` | +| What a branch did to it, and does it owe a changeset (§13) | `code-graph --api --api-base [--api-policy ]` | `--blast` requires an unambiguous target; pass the `id` (`file:name`), not a bare name. In default (`package`) scope, blast output is flagged incomplete for cross-package callers (§10). @@ -84,6 +86,10 @@ packages/code-graph/ hotspots/ Feature F — churn.ts + render.ts env-keys/ Feature H2 — extract.ts + query.ts data/ --data — access.ts (read/write per binding method) + extract.ts (call sites) + render.ts comments/ Feature I — classify.ts + census.ts + render.ts + ceilings.ts + ratchet.ts + gate.ts + api.ts barrel for the `./api` library subpath (§13.7) + api/ the published-API mode (§13) — map.ts + emit.ts + read.ts + text.ts + view.ts + + base.ts + diff.ts + cli.ts, and ratchet/ (§13.5): policy.ts + changesets.ts + + verdict.ts + text.ts ``` > **`render/` and `extract/` directory-sprawl gate:** `render/` holds one renderer per CLI output mode, and `extract/` holds one parser/scanner per extraction concern (cohesion, not sprawl) — both exceed the default `directorySprawl` (10). `selfcheck.thresholds.json` bumps `directorySprawl` to 16 for the self-gate ONLY — the shipped default is unchanged, and the number has not moved since. A feature that would push `render/` or `extract/` past it owns a DIRECTORY instead (`layers/`, `collapse/`, `hotspots/`, `env-keys/`, `schema/`): the fix for a full drawer is another drawer, never a bigger number (#4846). The `--html` feature is the pure model (`html-model.ts`), the inert page scaffold (`html-template.ts`), and a thin entry (`html.ts`); splitting it three ways keeps each file under the per-file `big-file`/`long-function` bars without relaxing those. `env-keys/extract.ts` (Feature H2) is the AST scan for `env.` reads, kept separate from `references.ts` (the call-graph reference walk) and `wrangler-config.ts` (the declared-key source) because the three answer different questions over different inputs — merging them would trade one cohesive file for one bloated one. @@ -377,6 +383,9 @@ code-graph [flags] | `--pretty` | Pretty-print JSON output | | `--json` | Force JSON output on a view command | | `--ci [--fail-on high\|warn] [--max ]` | Exit non-zero per policy: `--fail-on high` (default) fails only on `high` severity; `--max ` fails if smell count exceeds `n` | +| `--api ` | Published-API view: per export subpath, every published name with its emitted declaration text (§13.3). Standalone; reads emitted declarations, never the graph | +| `--api-base ` | With `--api`: the API diff against a base commit (§13.4) | +| `--api-policy ` | With `--api` and `--api-base`: the bump ratchet (§13.5). Exit 1 on a miss | **Emitting output — one way, banner-safe.** With no `--out`, a view writes its document to stdout (unchanged). To capture it to a file, prefer `--out `: because the `pnpm code-graph` wrapper prints its run banner to stdout, a bare `… > report.html` redirect interleaves that banner above the document (an invalid artifact, #1761), whereas `--out` writes the file directly and leaves the banner harmlessly on stdout. If you must redirect stdout instead, run banner-free — `pnpm --silent code-graph …` or `tsx tools/code-graph/src/index.ts …` directly. @@ -403,3 +412,402 @@ code-graph [flags] **Order rationale:** schema first (no rework, SSOT locked); cheap pass + summary next (the default output, most value, zero perf risk); IP (smells/plan) third; the only perf-sensitive pass (edges) last, kept cheap via inversion. Phase 4 makes the tool *discoverable*. **Verification discipline:** every phase validated against `services/audit-agents`, with ≥1 hand-checked number per phase before relying on the output in a real refactor. + +## 13. Published-API mode (`--api`) + +An opt-in mode that answers two questions about one package: what does each export subpath publish, as a consumer's types see it, and what did a change do to that. It has three steps over one input. The **view** (§13.3) lists the published names. The **diff** (§13.4) compares the view at a base commit with the view now. The **ratchet** (§13.5) checks the diff against the package's changesets and a bump policy. Each step adds one flag, and each later step runs the earlier ones. + +### 13.1 What it reads, and what it leaves alone + +- **Emitted declarations, not the oxc graph.** The mode runs the pinned tsgo itself (`tsgoBinary()` in `src/engine/tsgo.ts`) with declaration-only emit into a temp folder, then reads those `.d.ts` files with a tsgo program. It runs neither the cheap pass nor the edge pass and never reads the `Graph` or its nodes, including the nodes #599 and #600 add. Source text misses what a consumer gets: an inferred return type, a private type folded into a public name. The emitted declarations carry both. +- **§6-A3 still holds.** The `**/*.d.ts` exclusion filters the source graph's files. This mode's `.d.ts` files are the ones it emitted into its own temp folder, and they never enter the source graph. +- **Existing output does not change.** With none of `--api`, `--api-base` and `--api-policy` on the command line, no emit runs, no temp folder is made, and every existing flag's output is byte-identical to before: the summary, `Graph`, every report and every gate. The mode adds no key to `Graph` and changes no existing type. +- **The checkout is never written.** The mode writes only inside temp folders outside the checkout. It changes no file, index entry or ref in the checkout (§13.4 says how the base commit is read). + +### 13.2 Input: the API map + +The caller says which subpaths exist, which source file each comes from, and its tier. code-graph reads no `package.json` `exports`, no build config (`tsup.config.ts` or any other) and no `MAINTAINING.md`. + +`--api ` names a JSON file with this shape: + +```json +{ + ".": { "entry": "src/index.ts", "tier": "stable" }, + "./testing": { "entry": "src/testing/index.ts", "tier": "stable" }, + "./labs": { "entry": "src/labs/index.ts", "tier": "experimental" } +} +``` + +- **Keys** are export subpaths, spelled as the caller spells them. **`entry`** (required) is the source file for that subpath, relative to the analyzed ``. **`tier`** (optional) is any non-empty string. +- **The subpath-to-source map is the caller's.** code-graph holds no `dist/X` → `src/X` rule anywhere, in the CLI or the library. An export map points at build output, and only the package's own build config knows which source file each entry comes from, so any rule here would be a guess about one package's layout. This follows the ruling on #601 ([option (b)](https://github.com/kamp-us/demlik/issues/601#issuecomment-6102379089)), and the map has the same subpath → source shape as #601's `SubpathEntries`: an API map without its tiers is one. +- **A tier is an opaque string.** code-graph never interprets it. It copies the tier onto the view and the diff, and the ratchet uses it only as a key into the caller's policy (§13.5). `stable`, `battery` and `experimental` mean nothing to code-graph. A package that stamps its tiers in a `MAINTAINING.md` reads them with its own reader and writes them into the map. +- **Parse boundary.** The file is parsed through a zod schema (`ApiMapSchema`), as `--thresholds` is (§5). An unknown key, an empty map, an empty `entry` or an empty `tier` exits 2. So does an `entry` that is not a `.ts`, `.tsx`, `.mts` or `.cts` file, that lies outside ``, or that does not exist. + +### 13.3 The view + +`code-graph --api `, where `` is the package root. + +**Emit.** The tsconfig is the one package scope picks for ``: `resolveEdgeTsConfig(, "package", repoRoot)` (§8-C3); none exits 2. code-graph runs tsgo on it with `--noEmit false --declaration --emitDeclarationOnly --declarationMap false --noEmitOnError false --incremental false --composite false --rootDir --outDir /out`. Because code-graph sets `rootDir` and `outDir` itself, an entry's emitted file follows from the emit's own rule, not from any guess about the package: `src/testing/index.ts` emits to `/out/src/testing/index.d.ts` (`.tsx` → `.d.ts`, `.mts` → `.d.mts`, `.cts` → `.d.cts`). A type error does not stop the emit; tsgo's diagnostic count goes to stderr as one warning line. Exit 2 when tsgo fails to start, or when an entry has no emitted file (for example, the tsconfig does not include it). + +**Program.** A tsgo program opens over the emitted entry files with the tsconfig's compiler options. Bare specifiers resolve against the package's installed dependencies through a `node_modules` symlink at `/node_modules` → `/node_modules`, made in the temp folder, never in the checkout. + +**Names.** For each subpath, the entry's module symbol comes from the module step #601 adds to `src/resolve.ts` (the step behind `resolveModuleExport`). The view enumerates that symbol's exports with `exportsOf` and follows each through aliases, renames and `export *` with `aliasTarget`. It imports #601's step and never writes a second one. Every exported name is listed, types and values alike. A default export is the name `default`, and `export =` is the name `export=`. A name re-exported from a dependency is listed with its declaration text from the dependency's own `.d.ts`. + +**Text.** A name's `text` is the emitted text of each of its declarations, from the first token to the end of the statement (a variable's whole variable statement), with every comment removed, trailing whitespace trimmed, blank lines dropped and indentation kept as emitted. A doc-comment edit therefore changes no text. Several declarations (overloads, an interface merged with a namespace) join with `\n`, in emitted file order, then position. A renamed export keeps the declared name in its text: `export { plain as increment }` lists `increment` with the text of `declare function plain…`. + +**References.** A name also carries the text of every declaration it reaches that its subpath does not publish, so a change to a private type is a change to the published name that uses it. From each of the name's declarations, every type reference and `typeof` query resolves to a symbol. A declaration of that symbol is added, and walked in turn, when it sits in the emitted tree (`/out`) and is not the declaration of a name this subpath publishes. A published name the walk meets is not inlined, because its change shows on its own row. A declaration in a dependency is not followed. The key is `/out>#` (for example `src/core.d.ts#Options`), and the value is that declaration's text by the rule above. + +**JSON.** Output is a `PublishedApi`: + +```ts +type ApiEntryText = { + text: string; + references: Record; // "#" → text +}; + +type PublishedApi = { + root: string; // relative to cwd, as Graph.root + compiler: string; // the pinned tsgo version that emitted + subpaths: Record; + }>; +}; +``` + +Every subpath in the map is a key, including one that publishes nothing (`names: {}`). The JSON is serialized with sorted keys, like every code-graph output (§3). No key or text holds an absolute path, and the temp folder is removed before exit, on success and on failure. The same commit and the same map give the same bytes. It goes to stdout, or to `--out `; `--pretty` indents it. + +**Example.** A package `packages/demo` with three subpaths: + +```ts +// src/index.ts +export * from "./core"; + +// src/core.ts +type Options = { readonly retries: number }; +/** Builds a runner. */ +export function make(options: Options) { + return { options, started: false }; +} +export const VERSION = "1"; + +// src/testing/index.ts +export { expectEmitted } from "./expect"; + +// src/testing/expect.ts +export function expectEmitted(actual: readonly T[], expected: NoInfer): void {} + +// src/labs/index.ts +export const flag = true; +``` + +With the map in §13.2, `code-graph packages/demo --api demo-api.json --pretty` run from the repo root prints: + +```json +{ + "compiler": "7.0.0-dev.20260707.2", + "root": "packages/demo", + "subpaths": { + ".": { + "entry": "src/index.ts", + "names": { + "VERSION": { + "references": {}, + "text": "export declare const VERSION = \"1\";" + }, + "make": { + "references": { + "src/core.d.ts#Options": "type Options = {\n readonly retries: number;\n};" + }, + "text": "export declare function make(options: Options): {\n options: Options;\n started: boolean;\n};" + } + }, + "tier": "stable" + }, + "./labs": { + "entry": "src/labs/index.ts", + "names": { + "flag": { + "references": {}, + "text": "export declare const flag = true;" + } + }, + "tier": "experimental" + }, + "./testing": { + "entry": "src/testing/index.ts", + "names": { + "expectEmitted": { + "references": {}, + "text": "export declare function expectEmitted(actual: readonly T[], expected: NoInfer): void;" + } + }, + "tier": "stable" + } + } +} +``` + +`make`'s inferred return type shows in its text, and the private `Options` shows under its references. `compiler` is whatever version the package pins. + +### 13.4 The diff + +`code-graph --api --api-base `. + +**Reading the base.** `` resolves with `git rev-parse --verify ^{commit}` in the repository that holds ``. A rev that does not resolve exits 2. (In CI, fetch the base commit first: `actions/checkout` fetches one commit by default.) The base commit's whole tree is written from the repository's objects into a second temp folder, with `git archive ` piped into `tar -x -C /base`, so a tsconfig `extends` that points outside the package still resolves. `/base//node_modules` and `/base/node_modules` are symlinks to the checkout's installed `/node_modules` and repo-root `node_modules`, so the base resolves its dependencies against what is installed now. The view (§13.3) then runs on `/base/` with the same map. The only git commands the mode runs are `rev-parse`, `archive` and `ls-tree` (§13.5), all of which only read. It never runs `checkout`, `switch`, `stash`, `worktree`, `reset`, `read-tree`, `update-ref` or any other command that writes the checkout's files, index or refs. The diff child's test asserts that `git status --porcelain`, the index and `HEAD` are the same before and after a run. + +A subpath whose entry does not exist at the base has every name `added`. An entry missing now exits 2, as in the view. + +**Compare.** Per subpath, a name published now and not at the base is `added`. A name published at the base and not now is `removed`. A name in both whose `text` or `references` differ is `changed`. Equality is exact string equality on the text rules of §13.3. + +**JSON.** Output is an `ApiDiff`: + +```ts +type ApiDiff = { + root: string; // as PublishedApi.root + base: string; // the base commit's full sha + compiler: string; + subpaths: Record; + removed: Record; + changed: Record; + }>; +}; +``` + +Every subpath in the map is a key, including one with no change. The serialization and determinism rules of §13.3 apply. The diff reports and does not gate: it exits 0 whatever it finds. + +**Example.** Against the §13.3 package as the base (commit `4b1c0de…`), a branch adds `readonly delayMs?: number` to the private `Options`, drops `NoInfer` from `expectEmitted`, and replaces `./labs`'s `flag` with `export const level = 2;`. `code-graph packages/demo --api demo-api.json --api-base origin/main --pretty` prints (the `4b1c0de…` sha in full in the real output): + +```json +{ + "base": "4b1c0de…", + "compiler": "7.0.0-dev.20260707.2", + "root": "packages/demo", + "subpaths": { + ".": { + "added": {}, + "changed": { + "make": { + "after": { + "references": { + "src/core.d.ts#Options": "type Options = {\n readonly retries: number;\n readonly delayMs?: number;\n};" + }, + "text": "export declare function make(options: Options): {\n options: Options;\n started: boolean;\n};" + }, + "before": { + "references": { + "src/core.d.ts#Options": "type Options = {\n readonly retries: number;\n};" + }, + "text": "export declare function make(options: Options): {\n options: Options;\n started: boolean;\n};" + } + } + }, + "removed": {}, + "tier": "stable" + }, + "./labs": { + "added": { + "level": { "after": { "references": {}, "text": "export declare const level = 2;" } } + }, + "changed": {}, + "removed": { + "flag": { "before": { "references": {}, "text": "export declare const flag = true;" } } + }, + "tier": "experimental" + }, + "./testing": { + "added": {}, + "changed": { + "expectEmitted": { + "after": { + "references": {}, + "text": "export declare function expectEmitted(actual: readonly T[], expected: T): void;" + }, + "before": { + "references": {}, + "text": "export declare function expectEmitted(actual: readonly T[], expected: NoInfer): void;" + } + } + }, + "removed": {}, + "tier": "stable" + } + } +} +``` + +`make`'s own text did not move, but its private `Options` did, so `make` is `changed`. + +### 13.5 The ratchet + +`code-graph --api --api-base --api-policy `. + +**The bump policy** is the caller's, a JSON file parsed through `BumpPolicySchema`: + +```json +{ + "callout": "**Breaking", + "tiers": { + "stable": { + "added": { "bump": "minor" }, + "changed": { "bump": "minor", "callout": true }, + "removed": { "bump": "minor", "callout": true } + }, + "experimental": { + "added": { "bump": "none" }, + "changed": { "bump": "none" }, + "removed": { "bump": "none" } + } + } +} +``` + +- **`tiers`** maps a tier string to one row per change kind. Every row names all three kinds, `added`, `changed` and `removed`. A kind's rule is `bump`, one of `none` < `patch` < `minor` < `major`, and `callout`, a boolean that defaults to `false`. +- **`callout`** is the marker text, non-empty. A rule with `callout: true` is met only when a counted changeset's body contains this text. The marker is policy, not a constant, because each package marks a breaking change its own way. +- **`default`** is optional and has the shape of one `tiers` row. It applies to a subpath whose tier the policy does not name, and to a subpath with no tier. Without `default`, a diff row whose subpath's tier the policy does not name, or whose tier is `null`, exits 2 naming the subpath and tier, so no change passes unpoliced. +- An unknown key exits 2. code-graph ships no policy and no default row: every rule is the caller's, and it adds none stricter. + +**Which changesets count.** The changeset folder is `.changeset/` at the repo root (`findRepoRoot()`). A file counts when all four hold: + +1. It is a `.md` file directly in `.changeset/` and is not `README.md`. +2. It exists in the checkout's working tree. +3. It does not exist in the base commit's tree (`git ls-tree --name-only -- .changeset/`). +4. Its frontmatter names the package, the `name` in `/package.json`, with a bump of `major`, `minor` or `patch`. + +The frontmatter is the changesets format: a block between two `---` lines whose lines read `"": `. A file whose frontmatter does not parse exits 2 naming it. The body is everything after the closing `---`. A missing `/package.json`, or one with no `name`, exits 2. + +The **highest bump** is the largest bump the counted files give the package, or `none` when no file counts. The **callout is found** when any counted file's body contains the policy's `callout` text. + +**Verdict.** Each diff row (subpath, kind, name) takes its rule from the policy by its subpath's tier. A row misses when its rule's `bump` is above the highest bump, or when its rule has `callout: true` and no callout is found. Exit 0 when nothing misses, 1 when anything does. + +**Output.** The default is human text on stdout. A pass is one line: + +```text +API RATCHET: pass — 4 changes against 4b1c0de, highest changeset bump minor, callout found +``` + +A miss is one block per missing row, then a summary line: + +```text +API RATCHET: make in . (stable) changed — needs a minor changeset with a "**Breaking" callout; found none + before: + export declare function make(options: Options): { + options: Options; + started: boolean; + }; + src/core.d.ts#Options: type Options = { + readonly retries: number; + }; + after: + …the same, with the after text… +API RATCHET: 2 of 4 changes miss their bump against 4b1c0de +``` + +The block names the name, its subpath, its tier and its change kind, and prints the before and after text with references (an `added` row has no before, a `removed` row no after). `--json` prints an `ApiRatchetVerdict` instead: + +```ts +type Bump = "none" | "patch" | "minor" | "major"; + +type ApiRatchetVerdict = { + passed: boolean; + base: string; // full sha + package: string; // /package.json name + changesets: string[]; // counted files, repo-relative, sorted + highestBump: Bump; + calloutFound: boolean; + misses: Array<{ // sorted by subpath, then name, then kind + subpath: string; + tier: string | null; + name: string; + kind: "added" | "removed" | "changed"; + needs: { bump: Bump; callout: boolean }; + before: ApiEntryText | null; // null for added + after: ApiEntryText | null; // null for removed + }>; +}; +``` + +**Example.** On the §13.4 diff with the policy above and no changeset, `--json` prints: + +```json +{ + "base": "4b1c0de…", + "calloutFound": false, + "changesets": [], + "highestBump": "none", + "misses": [ + { + "after": { "references": { "src/core.d.ts#Options": "type Options = {\n readonly retries: number;\n readonly delayMs?: number;\n};" }, "text": "export declare function make(options: Options): {\n options: Options;\n started: boolean;\n};" }, + "before": { "references": { "src/core.d.ts#Options": "type Options = {\n readonly retries: number;\n};" }, "text": "export declare function make(options: Options): {\n options: Options;\n started: boolean;\n};" }, + "kind": "changed", + "name": "make", + "needs": { "bump": "minor", "callout": true }, + "subpath": ".", + "tier": "stable" + }, + { + "after": { "references": {}, "text": "export declare function expectEmitted(actual: readonly T[], expected: T): void;" }, + "before": { "references": {}, "text": "export declare function expectEmitted(actual: readonly T[], expected: NoInfer): void;" }, + "kind": "changed", + "name": "expectEmitted", + "needs": { "bump": "minor", "callout": true }, + "subpath": "./testing", + "tier": "stable" + } + ], + "package": "@demo/pkg", + "passed": false +} +``` + +and exits 1. The `./labs` rows need `none`, so they never miss. With `.changeset/demo-options.md` added on the branch: + +```md +--- +"@demo/pkg": minor +--- + +**Breaking:** `expectEmitted` no longer pins `expected` to `actual`'s type, and `make` takes `delayMs`. +``` + +the same run prints `"changesets": [".changeset/demo-options.md"]`, `"highestBump": "minor"`, `"calloutFound": true`, `"misses": []`, `"passed": true`, and exits 0. A `patch` changeset with the callout, or a `minor` one without it, still misses both rows. + +### 13.6 Flags and exit codes + +| Flags | Runs | Prints | Exit | +|---|---|---|---| +| `--api ` | view | `PublishedApi` JSON | 0, 2 | +| `--api --api-base ` | view at the base and now, then diff | `ApiDiff` JSON | 0, 2 | +| `--api --api-base --api-policy ` | the above, then ratchet | human verdict; `--json` gives `ApiRatchetVerdict` | 0 pass, 1 miss, 2 | + +`--json`, `--pretty` and `--out` apply as in §10; no other flag combines with `--api`. Exit 2, with one stderr line naming the cause and nothing written, for: + +- `--api-base` without `--api`, `--api-policy` without `--api-base`, or `--api` with any view, analysis or gate flag other than `--json`, `--pretty` and `--out`; +- an invalid map (§13.2) or policy (§13.5); +- no tsconfig for ``, tsgo failing to start, or an entry with no emitted file; +- a base rev that does not resolve, or a failed `git archive`; +- with `--api-policy`: no `name` in `/package.json`, a changeset whose frontmatter does not parse, or a tier with no policy row and no `default`. + +### 13.7 Library subpath `@demlik/code-graph/api` + +The mode ships as the `./api` export (`src/api.ts`, a barrel over `src/api/`), built and verified like the other subpaths: `package.json` `exports`, the tsup entry, and a row in `scripts/verify-exports.mjs`. + +| Export | Result | +|---|---| +| `readPublishedApi(root, map, options?)` | `Promise`: the view | +| `diffPublishedApi(root, map, base, options?)` | `Promise`: the diff; `base` is any rev | +| `readChangesetsSince(root, base, options?)` | `ChangesetsSince`: `{ package, changesets }`, where each `Changeset` is `{ file, bump, body }` and only counted files appear | +| `ratchetApiDiff(diff, policy, changesets)` | `ApiRatchetVerdict`; pure, no I/O | +| `ApiMapSchema`, `BumpPolicySchema` | the zod schemas the CLI parses through | + +It also exports the types `ApiMap`, `ApiEntryText`, `PublishedApi`, `ApiDiff`, `BumpPolicy`, `Bump`, `Changeset`, `ChangesetsSince`, `ApiRatchetVerdict` and the error class `ApiInputError`. `root` is the package root and resolves against cwd when relative. `options` is `{ repoRoot?: string }`, defaulting to `findRepoRoot(root)`. Every input the CLI refuses with exit 2 makes the library throw `ApiInputError` with the same message, and the CLI maps that error to exit 2. The CLI is a thin caller of these five exports and holds no logic of its own. + +### 13.8 Where it is documented + +- `docs/reference/cli.md` gains a "Published API" section: the three flags, what each prints, and the exit codes of §13.6, pointing here for the JSON. +- `docs/reference/library.md` gains an "API" section for `@demlik/code-graph/api`: the exports of §13.7, the map and policy shapes, and one example. + +Each child documents the part it builds: the view child `--api` and `readPublishedApi`, the diff child `--api-base` and `diffPublishedApi`, the ratchet child `--api-policy`, `readChangesetsSince` and `ratchetApiDiff`. diff --git a/packages/code-graph/docs/reference/cli.md b/packages/code-graph/docs/reference/cli.md index b19c038a..330159c7 100644 --- a/packages/code-graph/docs/reference/cli.md +++ b/packages/code-graph/docs/reference/cli.md @@ -63,6 +63,152 @@ the field is absent, and the output is unchanged. - A node with no body, such as an `interface` accessor, runs to its end without the closing `;` or `,`. +## Published API + +`--api ` prints what a package publishes, per export subpath, as a +consumer's types see it. `` is the package root. `` is a JSON +file the caller writes: + +```json +{ + ".": { "entry": "src/index.ts", "tier": "stable" }, + "./testing": { "entry": "src/testing/index.ts" } +} +``` + +Each key is a subpath. `entry` is its source file, relative to the package +root. `tier` is optional and any non-empty string; it is copied to the output +and never read. code-graph reads no `package.json` `exports` and no build +config: only the caller knows which source file a subpath comes from. + +The mode runs the pinned tsgo with declaration-only emit into a temp folder +outside the checkout, using the tsconfig the package scope picks, and removes +the folder before it exits. A type error does not stop the emit; tsgo's +diagnostic count goes to stderr as one warning line. It prints a +`PublishedApi` JSON object with sorted keys: the package `root`, the tsgo +`compiler` version, and per subpath its `entry`, `tier` (`null` when absent) +and `names`. Each name has its declaration `text` as emitted, without +comments, and `references`: the text of every declaration it reaches that the +subpath does not publish, keyed `#`. A change to +a private type therefore changes the published name that uses it. + +`--api-base ` diffs that view against a base commit. `` is any rev +git resolves to a commit (`origin/main`, a sha, `HEAD~1`); in CI, fetch it +first, because `actions/checkout` fetches one commit. The base commit's tree is +written from git's objects into a temp folder outside the checkout +(`git archive`), with the checkout's installed `node_modules` linked in, and +the same view runs there. The "after" side is the working tree as it is, +uncommitted edits and untracked files included. The checkout's files, index, +branch and stash are never written. It prints an `ApiDiff` JSON object: the +package `root`, the base commit's full sha as `base`, the tsgo `compiler`, and +per subpath its `tier` and three maps of names: `added` (with `after`), +`removed` (with `before`) and `changed` (with both). A name is `changed` when +its text or its references differ, so a change to a private type shows on the +published name that uses it. A subpath whose entry the base commit lacks has +every name `added`. The diff reports and does not gate: it exits 0 whatever it +finds. + +```sh +code-graph packages/tea --api tea-api.json --api-base origin/main --pretty +``` + +`--api-policy ` gates that diff on the package's changesets, for a CI +job. `` is a JSON bump policy the caller writes. code-graph ships no +policy and no default row: + +```json +{ + "callout": "**Breaking", + "tiers": { + "stable": { + "added": { "bump": "minor" }, + "changed": { "bump": "minor", "callout": true }, + "removed": { "bump": "major", "callout": true } + }, + "experimental": { + "added": { "bump": "none" }, + "changed": { "bump": "none" }, + "removed": { "bump": "none" } + } + } +} +``` + +- `tiers` maps a tier, as the API map spells it, to one rule per change kind. + All three kinds, `added`, `changed` and `removed`, are required. A rule's + `bump` is `none`, `patch`, `minor` or `major`, the least changeset bump that + change needs. Its `callout` is optional and defaults to `false`. +- `callout` is the marker text, required and non-empty. A rule with + `callout: true` is met only when a counted changeset's body contains it. +- `default` is optional and has the shape of one tier's row. It covers a + subpath whose tier `tiers` does not name, and a subpath with no tier. + Without it, a changed name in such a subpath exits 2 naming the subpath and + the tier. A subpath with no change needs no row. + +A changeset counts when it is a `.md` file directly in `.changeset/` at the +repo root, is not `README.md`, exists in the working tree, does not exist in +the base commit's tree, and its frontmatter gives the package (the `name` in +`/package.json`) a `major`, `minor` or `patch` bump. A changeset +for another package does not count. The highest bump among the counted files +is the bump found, `none` when no file counts, and the callout is found when +any counted file's body contains the marker. + +A changed name misses when its rule's bump is above the bump found, or when +its rule asks for the callout and none is found. A pass prints one line: + +```text +API RATCHET: pass — 4 changes against 4b1c0de, highest changeset bump minor, callout found +``` + +A miss prints one block per name that misses, sorted by subpath, then name, +then a summary line. Each block names the name, subpath, tier and change kind, +the bump and callout it needs, and what was found, then the before and after +text with references (an added name has no before, a removed name no after): + +```text +API RATCHET: parse in . (stable) changed — needs a minor changeset with a "**Breaking" callout; found minor with no callout + before: + export declare function parse(text: string): number; + after: + export declare function parse(text: string, radix?: number): number; +API RATCHET: 1 of 1 change misses its bump against 4b1c0de +``` + +`--json` prints an `ApiRatchetVerdict` instead: `passed`, the full `base` sha, +the `package` name, the counted `changesets` (repo-relative, sorted), +`highestBump`, `calloutFound` and `misses`, each with `subpath`, `tier`, +`name`, `kind`, `needs` (`{ bump, callout }`), `before` and `after` (`null` +where the name has none). The same commits give the same bytes. + +```sh +git fetch origin main +code-graph packages/tea --api tea-api.json --api-base origin/main --api-policy tea-bumps.json +``` + +| Flags | Prints | Exit | +|---|---|---| +| `--api ` | `PublishedApi` JSON | 0, 2 | +| `--api --api-base ` | `ApiDiff` JSON | 0, 2 | +| `--api --api-base --api-policy ` | the verdict as text; `--json` for `ApiRatchetVerdict` | 0 pass, 1 miss, 2 | + +`--api` combines only with `--api-base`, `--api-policy`, `--json`, `--pretty` +and `--out`. +Exit 2, with one stderr line and nothing on stdout or in `--out`, for any other +flag beside it, a map that is not JSON or fails the schema (an unknown key, no +subpath, an empty `entry` or `tier`, an entry that is not a `.ts`, `.tsx`, +`.mts` or `.cts` file, lies outside the package or does not exist), no +tsconfig, tsgo failing to start, or an entry with no emitted file. With +`--api-base`, also for `--api-base` without `--api`, a package outside any git +repository, a rev that does not resolve to a commit, or a failed `git archive`. +With `--api-policy`, also for `--api-policy` without `--api-base`, a policy +that is not JSON or fails the schema (an unknown key, an empty `callout`, a +row missing a change kind, a `bump` outside the four), a +`/package.json` that is missing or has no `name`, a changeset +added since the base whose frontmatter does not parse, or a changed name whose +tier has no row and no `default`. +Without `--api` no emit runs and every other output is unchanged. The full +contract, with examples, is [SPEC.md §13](../../SPEC.md). + ## Analysis options | Flag | Analysis | @@ -195,4 +341,4 @@ The option parser also rejects unknown flags. Check stderr for the specific error and for scope warnings. Source: [CLI options](../../src/cli.ts), [mode selection](../../src/index.ts), -[file discovery](../../src/extract/project.ts). +[file discovery](../../src/extract/project.ts), [published API](../../src/api/cli.ts). diff --git a/packages/code-graph/docs/reference/library.md b/packages/code-graph/docs/reference/library.md index 1730fb5f..95577cdf 100644 --- a/packages/code-graph/docs/reference/library.md +++ b/packages/code-graph/docs/reference/library.md @@ -26,7 +26,7 @@ Source: [project exports](../../src/project.ts). ## Resolve -`@demlik/code-graph/resolve` exports `loadInProcessGraph` and the types +`@demlik/code-graph/resolve` exports `loadInProcessGraph`, `moduleSymbolOf` and the types `InProcessGraph`, `InProcessGraphOptions`, `ExportOrigin`, `ExportDeclaration`, `SubpathEntries`, `SubpathExport`, `UnresolvableSubpath`, and `SubpathAnswer`. @@ -78,6 +78,12 @@ One tsgo session opens on the first lookup and is reused. Each lookup gets its own program. `dispose()` releases the session; create a new handle for later lookups. +`moduleSymbolOf(program, moduleFile)` is the step under `resolveModuleExport`: +the module symbol a file declares in an open tsgo program, or `undefined`. It +is exported for the published-API view below, which asks the same step; its +`program` argument is code-graph's own tsgo wrapper, so most callers want +`resolveModuleExport` instead. + Example, run in this checkout's `packages/code-graph` directory after building: ```ts @@ -109,6 +115,111 @@ try { Source: [resolver](../../src/resolve.ts). +## API + +`@demlik/code-graph/api` is the library side of `--api` (see the +[CLI reference](cli.md#published-api)). It exports: + +| Export | Result | +|---|---| +| `readPublishedApi(root, map, options?)` | `Promise`: per subpath, every published name with its text and references | +| `diffPublishedApi(root, map, base, options?)` | `Promise`: per subpath, the names added, removed and changed against the commit `base` names | +| `readChangesetsSince(root, base, options?)` | `ChangesetsSince`: the package's name and its changesets added since the commit `base` names | +| `ratchetApiDiff(diff, policy, changesets)` | `ApiRatchetVerdict`: the changes that miss the bump the policy asks for; pure, no I/O | +| `ApiMapSchema`, `BumpPolicySchema` | The zod schemas an API map and a bump policy are parsed through | +| `ApiInputError` | Thrown for every input the CLI refuses with exit 2, with the same message | + +and the types `ApiMap`, `ApiEntryText`, `PublishedApi`, `ApiDiff`, +`PublishedApiOptions`, `BumpPolicy`, `Bump`, `Changeset`, `ChangesetsSince`, +`ChangesetsOptions` and `ApiRatchetVerdict`. + +`root` is the package root and resolves against the working directory. `map` +is the API map, `{ "": { entry, tier? } }`, parsed through +`ApiMapSchema` before use. `options` is `{ repoRoot?, warn? }`: `repoRoot` +defaults to `findRepoRoot(root)`, and `warn` receives the one warning line for +an emit with diagnostics (stderr by default). The emit runs in a temp folder +outside the checkout and is removed before the promise settles. + +```ts +import { readPublishedApi } from "@demlik/code-graph/api"; + +const api = await readPublishedApi("packages/tea", { + ".": { entry: "src/index.ts", tier: "stable" }, + "./testing": { entry: "src/testing/index.ts", tier: "stable" }, +}); +api.subpaths["./testing"]?.names.expectCmdEmitted?.text; +// "export declare function expectCmdEmitted(…): void;" +``` + +`diffPublishedApi` takes the same `root`, `map` and `options`, plus `base`, any +rev git resolves to a commit in the repository that holds `root`. It writes +that commit's tree from git's objects into a temp folder outside the checkout, +links the checkout's installed `node_modules` into it, reads the same view +there, and compares it with the view of the working tree as it is. The +checkout's files, index, branch and stash are never written, and the temp +folders are removed before the promise settles. A rev that names no commit +throws `ApiInputError` before anything is emitted. Per subpath, the result has +the `tier` and `added` (`{ after }`), `removed` (`{ before }`) and `changed` +(`{ before, after }`) maps of `ApiEntryText`, with `base` as the full sha. + +```ts +import { diffPublishedApi } from "@demlik/code-graph/api"; + +const diff = await diffPublishedApi("packages/tea", map, "origin/main"); +diff.subpaths["./testing"]?.changed.expectCmdEmitted?.before.text; +// "…, cmd: NoInfer): void;" (and `.after.text` ends "…, cmd: C): void;") +``` + +`readChangesetsSince` and `ratchetApiDiff` are the library side of +`--api-policy`. `readChangesetsSince(root, base, options?)` is synchronous and +only reads. It returns `{ package, changesets }`: `package` is the `name` in +`root`'s `package.json`, and each `Changeset` is `{ file, bump, body }` for a +file that counts, with `file` repo-relative, `bump` one of `major`, `minor` +and `patch`, and `body` the text after the frontmatter. The +[CLI reference](cli.md#published-api) says which files count. `options` is +`{ repoRoot? }`, the folder that holds `.changeset/`, defaulting to +`findRepoRoot(root)`. A `package.json` with no `name`, or a changeset whose +frontmatter does not parse, throws `ApiInputError`. + +`ratchetApiDiff(diff, policy, changesets)` judges a diff and reads nothing but +its arguments. `policy` is the bump policy, parsed through `BumpPolicySchema` +before use: +`{ callout, tiers: { "": { added, changed, removed } }, default? }`, each +rule `{ bump, callout? }` with `bump` one of `none`, `patch`, `minor` and +`major`. The library holds no policy of its own. The verdict has `passed`, +`base`, `package`, the counted `changesets`, `highestBump`, `calloutFound` and +`misses`, sorted by subpath, then name, each with `subpath`, `tier`, `name`, +`kind`, `needs`, `before` and `after`. An invalid policy throws +`ApiInputError`, and so does a changed name whose tier the policy gives no row +and no `default`. + +```ts +import { + diffPublishedApi, + ratchetApiDiff, + readChangesetsSince, +} from "@demlik/code-graph/api"; + +const diff = await diffPublishedApi("packages/tea", map, "origin/main"); +const verdict = ratchetApiDiff( + diff, + { + callout: "**Breaking", + tiers: { + stable: { + added: { bump: "minor" }, + changed: { bump: "minor", callout: true }, + removed: { bump: "minor", callout: true }, + }, + }, + }, + readChangesetsSince("packages/tea", diff.base), +); +if (!verdict.passed) process.exitCode = 1; +``` + +Source: [API exports](../../src/api.ts). + ## SCC `@demlik/code-graph/scc` exports `stronglyConnectedComponents` and `sccMembers`. diff --git a/packages/code-graph/package.json b/packages/code-graph/package.json index 86c8e339..a853b154 100644 --- a/packages/code-graph/package.json +++ b/packages/code-graph/package.json @@ -34,6 +34,10 @@ "types": "./dist/boundaries.d.ts", "import": "./dist/boundaries.js" }, + "./api": { + "types": "./dist/api.d.ts", + "import": "./dist/api.js" + }, "./dist/*": "./dist/*" }, "files": [ diff --git a/packages/code-graph/scripts/verify-exports.mjs b/packages/code-graph/scripts/verify-exports.mjs index 89256b5a..cfe7d17a 100644 --- a/packages/code-graph/scripts/verify-exports.mjs +++ b/packages/code-graph/scripts/verify-exports.mjs @@ -24,8 +24,18 @@ const expected = { "rekeyBoundaryLedger", "rekeyBoundaryLedgerFile", ], + "@demlik/code-graph/api": [ + "readPublishedApi", + "diffPublishedApi", + "readChangesetsSince", + "ratchetApiDiff", + "ApiInputError", + ], }; +// Exports that are values but not functions: the zod schemas a caller parses through. +const schemas = { "@demlik/code-graph/api": ["ApiMapSchema", "BumpPolicySchema"] }; + // Removed from ./project in #397 with ts-morph: the loaders returned ts-morph's Project. const removed = { "@demlik/code-graph/project": ["loadEdgeProject", "loadCheapProject"] }; @@ -51,6 +61,10 @@ for (const [specifier, names] of Object.entries(expected)) { for (const name of names) { if (typeof mod[name] !== "function") failures.push(`${specifier}: no function export ${name}`); } + for (const name of schemas[specifier] ?? []) { + if (typeof mod[name]?.safeParse !== "function") + failures.push(`${specifier}: no schema export ${name}`); + } for (const name of removed[specifier] ?? []) { if (name in mod) failures.push(`${specifier}: still exports ${name}`); } diff --git a/packages/code-graph/src/api.ts b/packages/code-graph/src/api.ts new file mode 100644 index 00000000..05a69b7a --- /dev/null +++ b/packages/code-graph/src/api.ts @@ -0,0 +1,12 @@ +export { type ApiDiff, diffPublishedApi } from "./api/diff.js"; +export { ApiInputError, type ApiMap, ApiMapSchema } from "./api/map.js"; +export { + type Changeset, + type ChangesetsOptions, + type ChangesetsSince, + readChangesetsSince, +} from "./api/ratchet/changesets.js"; +export { type Bump, type BumpPolicy, BumpPolicySchema } from "./api/ratchet/policy.js"; +export { type ApiRatchetVerdict, ratchetApiDiff } from "./api/ratchet/verdict.js"; +export type { ApiEntryText } from "./api/read.js"; +export { type PublishedApi, type PublishedApiOptions, readPublishedApi } from "./api/view.js"; diff --git a/packages/code-graph/src/api/api.test.ts b/packages/code-graph/src/api/api.test.ts new file mode 100644 index 00000000..807d6202 --- /dev/null +++ b/packages/code-graph/src/api/api.test.ts @@ -0,0 +1,189 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ApiInputError, type PublishedApi, readPublishedApi } from "../api.js"; +import { tsgoBinary } from "../engine/tsgo.js"; +import { stableStringify } from "../render/json.js"; + +// The committed fixture package (test/api/fixture): four entries over a function, an overloaded +// function, a type alias, an interface, a class, a const with an inferred return type, two private +// types a published name reads, and `hidden`, which no entry publishes. +const here = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_DIR = path.resolve(here, "..", ".."); +const FIXTURE = path.join(PACKAGE_DIR, "test", "api", "fixture"); +const MAP_FILE = path.join(FIXTURE, "api-map.json"); +const CLI = path.join(PACKAGE_DIR, "src", "index.ts"); +const map: unknown = JSON.parse(fs.readFileSync(MAP_FILE, "utf8")); + +const STEP = { "src/fns.d.ts#Step": "type Step = {\n readonly by: number;\n};" }; +const PLAIN = "export declare function plain(n: number, step?: Step): number;"; + +const expectedSubpaths = (): PublishedApi["subpaths"] => ({ + ".": { + entry: "src/index.ts", + tier: "stable", + names: { + Alias: { text: "export type Alias = {\n readonly id: string;\n};", references: {} }, + Shape: { + text: "export interface Shape {\n readonly side: number;\n readonly corner: Corner;\n}", + references: { + "src/types.d.ts#Corner": + "interface Corner {\n readonly x: number;\n readonly y: number;\n}", + }, + }, + over: { + text: "export declare function over(a: string): string;\nexport declare function over(a: number): number;", + references: {}, + }, + plain: { text: PLAIN, references: STEP }, + }, + }, + "./relay": { + entry: "src/relay/index.ts", + tier: "experimental", + names: { LIMIT: { text: "export declare const LIMIT = 3;", references: {} } }, + }, + "./renamed": { + entry: "src/renamed/index.ts", + tier: null, + names: { increment: { text: PLAIN, references: STEP } }, + }, + "./testing": { + entry: "src/testing/index.ts", + tier: "stable", + names: { + Box: { text: "export declare class Box {\n readonly v = 1;\n}", references: {} }, + make: { text: "export declare const make: () => {\n a: number;\n};", references: {} }, + }, + }, +}); + +const quiet = { warn: () => {} }; +const scratch: string[] = []; + +// A copy of the fixture with one file rewritten, so a single change is the only difference. +function editedFixture(file: string, from: string, to: string): string { + const copy = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-api-test-")); + scratch.push(copy); + fs.cpSync(FIXTURE, copy, { recursive: true }); + const target = path.join(copy, file); + const before = fs.readFileSync(target, "utf8"); + expect(before).toContain(from); + fs.writeFileSync(target, before.replace(from, to)); + return copy; +} + +let view: PublishedApi; + +beforeAll(async () => { + view = await readPublishedApi(FIXTURE, map, quiet); +}); + +afterAll(() => { + for (const dir of scratch) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("readPublishedApi — the published-API view (SPEC §13.3)", () => { + it("lists exactly what each entry publishes, with its emitted text and references", async () => { + expect(view.subpaths).toEqual(expectedSubpaths()); + expect(view.compiler).toBe((await tsgoBinary()).version); + expect(view.root).toBe(path.relative(process.cwd(), fs.realpathSync(FIXTURE)) || "."); + }); + + it("lists a name no entry publishes under no subpath", () => { + const listed = Object.values(view.subpaths).flatMap((subpath) => Object.keys(subpath.names)); + expect(listed).not.toContain("hidden"); + expect(JSON.stringify(view)).not.toContain("hidden"); + }); + + it("changes a published name's references when only a private type it reads changes", async () => { + const root = editedFixture("src/fns.ts", "readonly by: number", "readonly by: string"); + const edited = await readPublishedApi(root, map, quiet); + const changed = { "src/fns.d.ts#Step": "type Step = {\n readonly by: string;\n};" }; + expect(edited.subpaths["."]?.names.plain).toEqual({ text: PLAIN, references: changed }); + expect(edited.subpaths["./renamed"]?.names.increment).toEqual({ + text: PLAIN, + references: changed, + }); + expect(edited.subpaths["./testing"]).toEqual(view.subpaths["./testing"]); + }); + + it("changes a const's text when only its inferred return type changes", async () => { + const root = editedFixture("src/values.ts", "({ a: 1 })", '({ a: "1" })'); + const edited = await readPublishedApi(root, map, quiet); + expect(edited.subpaths["./testing"]?.names.make?.text).toBe( + "export declare const make: () => {\n a: string;\n};", + ); + }); + + it("drops every comment from a text", async () => { + const root = editedFixture( + "src/types.ts", + "readonly side: number;", + "/* kept by the emit */ readonly side: number; // and this", + ); + const edited = await readPublishedApi(root, map, quiet); + expect(edited.subpaths["."]?.names.Shape).toEqual(view.subpaths["."]?.names.Shape); + }); + + it("gives the same bytes on a second run", async () => { + const again = await readPublishedApi(FIXTURE, map, quiet); + expect(stableStringify(again, true)).toBe(stableStringify(view, true)); + }); +}); + +describe("readPublishedApi — inputs it refuses (SPEC §13.2)", () => { + const refusals: ReadonlyArray = [ + ["an empty map", {}], + ["an unknown key", { ".": { entry: "src/index.ts", extra: true } }], + ["an empty entry", { ".": { entry: "" } }], + ["an empty tier", { ".": { entry: "src/index.ts", tier: "" } }], + ["an entry that is not TypeScript source", { ".": { entry: "api-map.json" } }], + ["an entry outside the package", { ".": { entry: "../../../src/api.ts" } }], + ["an entry that does not exist", { ".": { entry: "src/missing.ts" } }], + ]; + + it.each(refusals)("refuses %s with ApiInputError", async (_, bad) => { + await expect(readPublishedApi(FIXTURE, bad, quiet)).rejects.toBeInstanceOf(ApiInputError); + }); +}); + +describe("code-graph --api", () => { + const run = (args: readonly string[]) => { + try { + const stdout = execFileSync(process.execPath, ["--import", "tsx", CLI, FIXTURE, ...args], { + cwd: PACKAGE_DIR, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (error) { + const failed = error as { status: number; stdout: string; stderr: string }; + return { code: failed.status, stdout: failed.stdout, stderr: failed.stderr }; + } + }; + + it("prints the library's view as sorted JSON", async () => { + const printed = run(["--api", MAP_FILE]); + expect(printed.code).toBe(0); + const fromCli = JSON.parse(printed.stdout) as PublishedApi; + expect(fromCli.subpaths).toEqual(expectedSubpaths()); + expect(printed.stdout).toBe(`${stableStringify(JSON.parse(printed.stdout), false)}\n`); + }); + + it("exits 2 and prints nothing beside a flag it does not take", () => { + const refused = run(["--api", MAP_FILE, "--plan"]); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain("--plan"); + }); + + it("exits 2 on a map file that is not JSON", () => { + const refused = run(["--api", path.join(FIXTURE, "src", "index.ts")]); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + }); +}); diff --git a/packages/code-graph/src/api/base.ts b/packages/code-graph/src/api/base.ts new file mode 100644 index 00000000..b1e8b565 --- /dev/null +++ b/packages/code-graph/src/api/base.ts @@ -0,0 +1,118 @@ +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ApiInputError } from "./map.js"; + +// The git repository that holds a package (SPEC §13.4). Every git command here only reads: +// `rev-parse`, `archive` and `ls-tree`. Nothing writes the checkout's files, index or refs. +export type Checkout = { + readonly top: string; + // The package root, relative to `top`. + readonly packagePath: string; +}; + +function gitRead(cwd: string, args: readonly string[]) { + return spawnSync("git", args, { cwd, encoding: "utf8" }); +} + +export function checkoutOf(rootAbsolute: string): Checkout { + const run = gitRead(rootAbsolute, ["rev-parse", "--show-toplevel"]); + if (run.error !== undefined || run.status !== 0) { + throw new ApiInputError( + `${rootAbsolute} is not inside a git repository; --api-base reads its base commit from git`, + ); + } + const top = fs.realpathSync(run.stdout.trim()); + return { top, packagePath: path.relative(top, rootAbsolute) }; +} + +// The full sha `rev` names, or `ApiInputError` when it names no commit. +export function resolveBase(checkout: Checkout, rev: string): string { + const run = gitRead(checkout.top, [ + ...["rev-parse", "--verify", "--quiet", "--end-of-options"], + `${rev}^{commit}`, + ]); + if (run.error !== undefined || run.status !== 0) { + throw new ApiInputError( + `base rev "${rev}" does not resolve to a commit in ${checkout.top} (in CI, fetch it first)`, + ); + } + return run.stdout.trim(); +} + +// The names the commit `sha` holds directly under `dir`, relative to `cwd` (a folder inside the +// repository): `git ls-tree --name-only -- `. A folder the commit lacks lists nothing. +export function namesAtBase(cwd: string, sha: string, dir: string): readonly string[] { + const run = gitRead(cwd, ["ls-tree", "-z", "--name-only", sha, "--", dir]); + if (run.error !== undefined || run.status !== 0) { + throw new ApiInputError( + `git ls-tree of ${sha} failed: ${run.stderr?.trim() || run.error?.message || "no output"}`, + ); + } + return run.stdout.split("\0").filter((name) => name !== ""); +} + +const exited = (child: ReturnType): Promise => + new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code)); + }); + +// `git archive | tar -x -C `: the commit's whole tree from the repository's objects. +async function archiveInto(top: string, sha: string, into: string): Promise { + const archive = spawn("git", ["archive", "--format=tar", sha], { + cwd: top, + stdio: ["ignore", "pipe", "pipe"], + }); + const extract = spawn("tar", ["-x", "-f", "-", "-C", into], { + stdio: ["pipe", "ignore", "pipe"], + }); + const errors: string[] = []; + archive.stderr?.on("data", (chunk) => errors.push(String(chunk))); + extract.stderr?.on("data", (chunk) => errors.push(String(chunk))); + // A tar that dies early closes its stdin under git; the exit codes below report that, so the + // pipe error itself is not a second failure. + extract.stdin?.on("error", () => {}); + archive.stdout?.pipe(extract.stdin as NodeJS.WritableStream); + const codes = await Promise.all([exited(archive), exited(extract)]).catch((error: Error) => { + throw new ApiInputError(`git archive of ${sha} failed: ${error.message}`); + }); + if (codes.some((code) => code !== 0)) { + throw new ApiInputError( + `git archive of ${sha} failed: ${errors.join("").trim() || "no output"}`, + ); + } +} + +function linkInstalled(installed: string, link: string): void { + if (!fs.existsSync(installed) || !fs.existsSync(path.dirname(link))) return; + if (fs.existsSync(link)) return; + fs.symlinkSync(installed, link, "dir"); +} + +// A base commit's tree, written into a temp folder outside the checkout. `root` is the package +// root inside it, which may not exist when the commit predates the package. +export type BaseTree = { readonly temp: string; readonly root: string }; + +// Writes `sha`'s tree into a temp folder and links the checkout's installed `node_modules` (the +// package's and the repository root's) into it, so the base resolves its dependencies against +// what is installed now. The caller removes the folder with `removeBaseTree`. +export async function exportBaseTree(checkout: Checkout, sha: string): Promise { + const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-api-base-"))); + try { + await archiveInto(checkout.top, sha, temp); + const root = path.join(temp, checkout.packagePath); + const packageNow = path.join(checkout.top, checkout.packagePath); + linkInstalled(path.join(packageNow, "node_modules"), path.join(root, "node_modules")); + linkInstalled(path.join(checkout.top, "node_modules"), path.join(temp, "node_modules")); + return { temp, root }; + } catch (error) { + removeBaseTree({ temp }); + throw error; + } +} + +export function removeBaseTree(tree: { readonly temp: string }): void { + fs.rmSync(tree.temp, { recursive: true, force: true }); +} diff --git a/packages/code-graph/src/api/cli.ts b/packages/code-graph/src/api/cli.ts new file mode 100644 index 00000000..da5b255b --- /dev/null +++ b/packages/code-graph/src/api/cli.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import { stableStringify } from "../render/json.js"; +import { diffPublishedApi } from "./diff.js"; +import { ApiInputError } from "./map.js"; +import { readChangesetsSince } from "./ratchet/changesets.js"; +import { bumpPolicy } from "./ratchet/policy.js"; +import { renderApiRatchet } from "./ratchet/text.js"; +import { apiChanges, ratchetApiDiff } from "./ratchet/verdict.js"; +import { readPublishedApi } from "./view.js"; + +// The flags `--api` combines with (SPEC §13.6); any other flag given on the command line exits 2. +const API_COMPANIONS: ReadonlySet = new Set([ + ...["api", "apiBase", "apiPolicy"], + ...["json", "pretty", "out"], +]); + +export type ApiRun = { + readonly rootAbsolute: string; + readonly mapFile: string; + // `--api-base`: when set, the run diffs against this rev instead of printing the view. + readonly base: string | undefined; + // `--api-policy`: when set, the run judges the diff against this policy instead of printing it. + readonly policyFile: string | undefined; + // Every option the command line itself gave, by its attribute name. + readonly given: readonly string[]; + // `--json`: the ratchet prints its verdict as JSON; the view and the diff are JSON either way. + readonly json: boolean; + readonly pretty: boolean; + readonly emit: (payload: string) => void; + readonly report: (message: string) => void; +}; + +// The mode's three flags as the command line gave them. +export type ApiFlags = { + readonly api?: string; + readonly apiBase?: string; + readonly apiPolicy?: string; +}; + +type ApiOutcome = { readonly payload: string; readonly code: number }; + +function readJsonFile(file: string, what: string): unknown { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + throw new ApiInputError(`cannot read ${what} "${file}"`); + } + try { + return JSON.parse(raw); + } catch { + throw new ApiInputError(`${what} "${file}" is not valid JSON`); + } +} + +const flagOf = (attribute: string): string => + `--${attribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + +// The ratchet (SPEC §13.5): the diff, the package's changesets since the diff's base, and the +// verdict. The policy is parsed before the diff runs, so a bad one is refused before any emit. +async function ratchet( + run: ApiRun, + map: unknown, + base: string, + policyFile: string, +): Promise { + const policy = bumpPolicy(readJsonFile(policyFile, "bump policy")); + const diff = await diffPublishedApi(run.rootAbsolute, map, base); + const verdict = ratchetApiDiff(diff, policy, readChangesetsSince(run.rootAbsolute, diff.base)); + const payload = run.json + ? `${stableStringify(verdict, run.pretty)}\n` + : renderApiRatchet(verdict, { changes: apiChanges(diff).length, callout: policy.callout }); + return { payload, code: verdict.passed ? 0 : 1 }; +} + +async function outcomeOf(run: ApiRun): Promise { + const map = readJsonFile(run.mapFile, "API map"); + if (run.base !== undefined && run.policyFile !== undefined) { + return ratchet(run, map, run.base, run.policyFile); + } + const result = + run.base === undefined + ? await readPublishedApi(run.rootAbsolute, map) + : await diffPublishedApi(run.rootAbsolute, map, run.base); + return { payload: `${stableStringify(result, run.pretty)}\n`, code: 0 }; +} + +// A thin caller of the `@demlik/code-graph/api` exports: it holds no logic of its own beyond +// reading the map and policy files and refusing flags the mode does not take. Resolves to the exit +// code: 0, 1 when the ratchet finds a miss, 2 on a refused input. +export async function runApiView(run: ApiRun): Promise { + const stray = run.given.filter((attribute) => !API_COMPANIONS.has(attribute)); + if (stray.length > 0) { + run.report( + `--api takes only --api-base, --api-policy, --json, --pretty and --out; got ${stray.map(flagOf).join(", ")}`, + ); + return 2; + } + if (run.policyFile !== undefined && run.base === undefined) { + run.report("--api-policy needs --api-base "); + return 2; + } + try { + const { payload, code } = await outcomeOf(run); + run.emit(payload); + return code; + } catch (error) { + if (!(error instanceof ApiInputError)) throw error; + run.report(error.message); + return 2; + } +} + +// The command line's entry to the mode: `null` when none of its flags is given, so every other +// mode runs as before; otherwise the run's exit code. `--api-base` and `--api-policy` without +// `--api` are refused here (SPEC §13.6). +export function runApiFlags( + flags: ApiFlags, + host: Omit, +): Promise | null { + if (flags.api !== undefined) { + return runApiView({ + ...host, + mapFile: flags.api, + base: flags.apiBase, + policyFile: flags.apiPolicy, + }); + } + if (flags.apiBase === undefined && flags.apiPolicy === undefined) return null; + host.report( + flags.apiBase === undefined + ? "--api-policy needs --api --api-base " + : "--api-base needs --api ", + ); + return Promise.resolve(2); +} diff --git a/packages/code-graph/src/api/diff.test.ts b/packages/code-graph/src/api/diff.test.ts new file mode 100644 index 00000000..b432cdcb --- /dev/null +++ b/packages/code-graph/src/api/diff.test.ts @@ -0,0 +1,240 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { type ApiDiff, ApiInputError, diffPublishedApi } from "../api.js"; +import { stableStringify } from "../render/json.js"; + +// The committed fixture (test/api/diff): one package at two commits. `before/` is the base and +// `after/` the branch: across three entries it adds a name (`format`), removes one (`legacy`), +// changes a signature (`parse`), changes a private type a published name reads (`Options`, read by +// `make`), renames a re-export (`check` → `verify`), adds a subpath (`./labs`) and leaves +// `VERSION` and `expectEqual`'s text alone. +const here = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_DIR = path.resolve(here, "..", ".."); +const FIXTURE = path.join(PACKAGE_DIR, "test", "api", "diff"); +const MAP_FILE = path.join(FIXTURE, "api-map.json"); +const CLI = path.join(PACKAGE_DIR, "src", "index.ts"); +const map: unknown = JSON.parse(fs.readFileSync(MAP_FILE, "utf8")); +const quiet = { warn: () => {} }; + +const git = (cwd: string, ...args: string[]): string => + execFileSync( + "git", + ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", ...args], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + +const scratch: string[] = []; + +// A throwaway repository whose package sits below its top, at `packages/demo`, with the `before` +// tree committed and the `after` tree committed on top. Returns the package root. +function throwawayRepository(): string { + const top = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-api-repo-"))); + scratch.push(top); + const root = path.join(top, "packages", "demo"); + git(top, "init", "--quiet", "--initial-branch=main"); + fs.cpSync(path.join(FIXTURE, "before"), root, { recursive: true }); + git(top, "add", "-A"); + git(top, "commit", "--quiet", "--no-gpg-sign", "-m", "before"); + fs.rmSync(root, { recursive: true }); + fs.cpSync(path.join(FIXTURE, "after"), root, { recursive: true }); + git(top, "add", "-A"); + git(top, "commit", "--quiet", "--no-gpg-sign", "-m", "after"); + return root; +} + +function filesUnder(dir: string, top = dir): readonly string[] { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.name !== ".git") + .flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return filesUnder(full, top); + const bytes = createHash("sha256").update(fs.readFileSync(full)).digest("hex"); + return [`${path.relative(top, full)} ${bytes}`]; + }) + .sort(); +} + +// Everything a diff run must leave as it found it: the files' bytes, the porcelain status, the +// index, the branch and HEAD, and the stash list. +function checkoutState(root: string) { + const top = git(root, "rev-parse", "--show-toplevel").trim(); + return { + files: filesUnder(top), + status: git(top, "--no-optional-locks", "status", "--porcelain=v1", "--untracked-files=all"), + index: git(top, "ls-files", "--stage"), + branch: git(top, "symbolic-ref", "HEAD"), + head: git(top, "rev-parse", "HEAD"), + stash: git(top, "stash", "list"), + }; +} + +const MAKE = "export declare function make(options: Options): {\n options: Options;\n};"; +const OPTIONS = "src/core.d.ts#Options"; +const EXPECT_EQUAL = + "export declare function expectEqual(actual: T, expected: NoInfer): boolean;"; + +const expectedSubpaths = (): ApiDiff["subpaths"] => ({ + ".": { + tier: "stable", + added: { + format: { + after: { text: "export declare function format(n: number): string;", references: {} }, + }, + }, + removed: { + legacy: { before: { text: "export declare function legacy(): void;", references: {} } }, + }, + changed: { + make: { + before: { + text: MAKE, + references: { [OPTIONS]: "type Options = {\n readonly retries: number;\n};" }, + }, + after: { + text: MAKE, + references: { + [OPTIONS]: + "type Options = {\n readonly retries: number;\n readonly delayMs?: number;\n};", + }, + }, + }, + parse: { + before: { text: "export declare function parse(text: string): number;", references: {} }, + after: { + text: "export declare function parse(text: string, radix?: number): number;", + references: {}, + }, + }, + }, + }, + "./labs": { + tier: "experimental", + added: { flag: { after: { text: "export declare const flag = true;", references: {} } } }, + removed: {}, + changed: {}, + }, + "./testing": { + tier: "stable", + added: { verify: { after: { text: EXPECT_EQUAL, references: {} } } }, + removed: { check: { before: { text: EXPECT_EQUAL, references: {} } } }, + changed: {}, + }, +}); + +let root: string; +let base: string; +let diff: ApiDiff; +let before: ReturnType; + +beforeAll(async () => { + root = throwawayRepository(); + base = git(root, "rev-parse", "HEAD~1").trim(); + before = checkoutState(root); + diff = await diffPublishedApi(root, map, "HEAD~1", quiet); +}); + +afterAll(() => { + for (const dir of scratch) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("diffPublishedApi — the published-API diff (SPEC §13.4)", () => { + it("reports each added, removed and changed name under its subpath, and nothing else", () => { + expect(diff.subpaths).toEqual(expectedSubpaths()); + expect(diff.base).toBe(base); + expect(diff.root).toBe(path.relative(process.cwd(), root) || "."); + }); + + it("does not report a name whose text and references did not move", () => { + expect(JSON.stringify(diff)).not.toContain("VERSION"); + }); + + it("leaves the checkout's files, status, index, branch and stash as they were", () => { + expect(checkoutState(root)).toEqual(before); + }); + + it("gives the same bytes on a second run", async () => { + const again = await diffPublishedApi(root, map, base, quiet); + expect(stableStringify(again, true)).toBe(stableStringify(diff, true)); + }); + + it("reads a dirty working tree as the after side, and leaves it byte-identical", async () => { + const dirty = throwawayRepository(); + const core = path.join(dirty, "src", "core.ts"); + fs.appendFileSync(core, "export const DIRTY = 1;\n"); + fs.writeFileSync(path.join(dirty, "src", "scratch.ts"), "export const untracked = true;\n"); + fs.writeFileSync( + path.join(dirty, "src", "index.ts"), + 'export * from "./core";\nexport * from "./scratch";\n', + ); + const state = checkoutState(dirty); + expect(state.status).not.toBe(""); + + const dirtyDiff = await diffPublishedApi(dirty, map, "HEAD~1", quiet); + + expect(checkoutState(dirty)).toEqual(state); + expect(Object.keys(dirtyDiff.subpaths["."]?.added ?? {}).sort()).toEqual([ + "DIRTY", + "format", + "untracked", + ]); + }); + + it("refuses a base that names no commit, and writes nothing", async () => { + const state = checkoutState(root); + await expect(diffPublishedApi(root, map, "no-such-ref", quiet)).rejects.toThrow( + new ApiInputError( + `base rev "no-such-ref" does not resolve to a commit in ${path.dirname(path.dirname(root))} (in CI, fetch it first)`, + ), + ); + expect(checkoutState(root)).toEqual(state); + }); +}); + +describe("code-graph --api --api-base", () => { + const run = (cwd: string, args: readonly string[]) => { + try { + const stdout = execFileSync(process.execPath, ["--import", "tsx", CLI, ...args], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (error) { + const failed = error as { status: number; stdout: string; stderr: string }; + return { code: failed.status, stdout: failed.stdout, stderr: failed.stderr }; + } + }; + + it("prints the library's diff as sorted JSON", () => { + const printed = run(PACKAGE_DIR, [root, "--api", MAP_FILE, "--api-base", "HEAD~1"]); + expect(printed.code).toBe(0); + expect((JSON.parse(printed.stdout) as ApiDiff).subpaths).toEqual(expectedSubpaths()); + expect(printed.stdout).toBe(`${stableStringify(JSON.parse(printed.stdout), false)}\n`); + }); + + it("exits 2 on a base that names no commit, writing no --out file and leaving the checkout", () => { + const state = checkoutState(root); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-api-out-")); + scratch.push(outDir); + const out = path.join(outDir, "diff.json"); + const refused = run(PACKAGE_DIR, [root, "--api", MAP_FILE, "--api-base", "nope", "--out", out]); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain('base rev "nope" does not resolve to a commit'); + expect(fs.existsSync(out)).toBe(false); + expect(checkoutState(root)).toEqual(state); + }); + + it("exits 2 on --api-base without --api", () => { + const refused = run(PACKAGE_DIR, [root, "--api-base", "HEAD~1"]); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain("--api-base needs --api"); + }); +}); diff --git a/packages/code-graph/src/api/diff.ts b/packages/code-graph/src/api/diff.ts new file mode 100644 index 00000000..d0a0dd25 --- /dev/null +++ b/packages/code-graph/src/api/diff.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; +import { checkoutOf, exportBaseTree, removeBaseTree, resolveBase } from "./base.js"; +import { type ApiSubpath, apiSubpaths } from "./map.js"; +import type { ApiEntryText, SubpathNames } from "./read.js"; +import { + type EmittedNames, + type PublishedApiOptions, + packageRoot, + projectRelative, + readEmittedNames, +} from "./view.js"; + +export type SubpathDiff = { + readonly tier: string | null; + readonly added: Readonly>; + readonly removed: Readonly>; + readonly changed: Readonly< + Record + >; +}; + +// What a change did to a package's published API against a base commit (SPEC §13.4). +export type ApiDiff = { + readonly root: string; + readonly base: string; + readonly compiler: string; + readonly subpaths: Readonly>; +}; + +function sameReferences(a: ApiEntryText["references"], b: ApiEntryText["references"]): boolean { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key]) + ); +} + +const sameEntry = (a: ApiEntryText, b: ApiEntryText): boolean => + a.text === b.text && sameReferences(a.references, b.references); + +// One subpath's names at the base against its names now: exact string equality on text and +// references. +export function diffSubpathNames( + tier: string | null, + before: SubpathNames, + after: SubpathNames, +): SubpathDiff { + const added: Record = {}; + const removed: Record = {}; + const changed: Record = {}; + for (const [name, now] of Object.entries(after)) { + const then = Object.hasOwn(before, name) ? before[name] : undefined; + if (then === undefined) added[name] = { after: now }; + else if (!sameEntry(then, now)) changed[name] = { before: then, after: now }; + } + for (const [name, then] of Object.entries(before)) { + if (!Object.hasOwn(after, name)) removed[name] = { before: then }; + } + return { tier, added, removed, changed }; +} + +// The map's subpaths as they stand in the base tree: a subpath whose entry the base commit lacks is +// left out, so every name it publishes now is `added`. +function subpathsAtBase(baseRoot: string, subpaths: readonly ApiSubpath[]): readonly ApiSubpath[] { + return subpaths.flatMap((subpath) => { + const entryAbsolute = path.join(baseRoot, subpath.entry); + return fs.existsSync(entryAbsolute) && fs.statSync(entryAbsolute).isFile() + ? [{ ...subpath, entryAbsolute }] + : []; + }); +} + +async function readBaseNames( + rootAbsolute: string, + sha: string, + subpaths: readonly ApiSubpath[], + options: PublishedApiOptions, +): Promise { + const checkout = checkoutOf(rootAbsolute); + const tree = await exportBaseTree(checkout, sha); + try { + const present = subpathsAtBase(tree.root, subpaths); + if (present.length === 0) return new Map(); + const { names } = await readEmittedNames(tree.root, present, { + ...options, + repoRoot: tree.temp, + context: ` at the base ${sha.slice(0, 12)}`, + }); + return names; + } finally { + removeBaseTree(tree); + } +} + +// The published-API diff of the package at `root` against the commit `base` names (SPEC §13.4). +// The base tree is read from the repository's objects into a temp folder outside the checkout; +// the checkout's files, index and refs are never written. The "after" side is the working tree as +// it is, uncommitted edits included. +export async function diffPublishedApi( + root: string, + map: unknown, + base: string, + options: PublishedApiOptions = {}, +): Promise { + const rootAbsolute = packageRoot(root); + const subpaths = apiSubpaths(rootAbsolute, map); + const sha = resolveBase(checkoutOf(rootAbsolute), base); + const now = await readEmittedNames(rootAbsolute, subpaths, options); + const before = await readBaseNames(rootAbsolute, sha, subpaths, options); + const diff: Record = {}; + for (const { subpath, tier } of subpaths) { + diff[subpath] = diffSubpathNames(tier, before.get(subpath) ?? {}, now.names.get(subpath) ?? {}); + } + return { root: projectRelative(rootAbsolute), base: sha, compiler: now.compiler, subpaths: diff }; +} diff --git a/packages/code-graph/src/api/emit.ts b/packages/code-graph/src/api/emit.ts new file mode 100644 index 00000000..5524f5d8 --- /dev/null +++ b/packages/code-graph/src/api/emit.ts @@ -0,0 +1,89 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { tsgoBinary } from "../engine/tsgo.js"; +import { ApiInputError, type ApiSubpath } from "./map.js"; + +// A declaration emit of one package into a temp folder outside the checkout (SPEC §13.3). The +// emit's root is the package root, so an entry's emitted file follows from the emit's own rule: +// `src/a/index.ts` → `/src/a/index.d.ts`. +export type DeclarationEmit = { + readonly temp: string; + readonly out: string; + readonly compiler: string; + readonly diagnostics: number; +}; + +const EMITTED_EXTENSION: ReadonlyArray = [ + [/\.mts$/, ".d.mts"], + [/\.cts$/, ".d.cts"], + [/\.tsx?$/, ".d.ts"], +]; + +export function emittedFileOf(emit: DeclarationEmit, rootAbsolute: string, entry: string): string { + const relative = path.relative(rootAbsolute, entry); + for (const [source, declaration] of EMITTED_EXTENSION) { + if (source.test(relative)) return path.join(emit.out, relative.replace(source, declaration)); + } + return path.join(emit.out, relative); +} + +function diagnosticCount(output: string, status: number | null): number { + const found = /Found (\d+) errors?/.exec(output); + if (found !== null) return Number(found[1]); + return status === 0 ? 0 : 1; +} + +// The package's installed dependencies, seen from the temp folder, so the emitted files' bare +// specifiers resolve as the source's did. The link lives in the temp folder, never the checkout. +function linkDependencies(temp: string, rootAbsolute: string): void { + const installed = path.join(rootAbsolute, "node_modules"); + if (fs.existsSync(installed)) fs.symlinkSync(installed, path.join(temp, "node_modules"), "dir"); +} + +export function removeEmit(emit: { readonly temp: string }): void { + fs.rmSync(emit.temp, { recursive: true, force: true }); +} + +// Runs the pinned tsgo with declaration-only emit. A type error does not stop the emit; it is +// counted in `diagnostics`. The caller removes the temp folder with `removeEmit`. +export async function emitDeclarations( + rootAbsolute: string, + tsConfigPath: string, +): Promise { + const tsgo = await tsgoBinary(); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-api-"))); + const out = path.join(temp, "out"); + const args = [ + ...["-p", tsConfigPath, "--noEmit", "false", "--declaration", "--emitDeclarationOnly"], + ...["--declarationMap", "false", "--noEmitOnError", "false", "--incremental", "false"], + ...["--composite", "false", "--rootDir", rootAbsolute, "--outDir", out], + ]; + const run = spawnSync(tsgo.path, args, { cwd: rootAbsolute, encoding: "utf8" }); + if (run.error !== undefined) { + removeEmit({ temp }); + throw new ApiInputError(`tsgo failed to start: ${run.error.message}`); + } + linkDependencies(temp, rootAbsolute); + const diagnostics = diagnosticCount(`${run.stdout}${run.stderr}`, run.status); + return { temp, out, compiler: tsgo.version, diagnostics }; +} + +export function emittedEntries( + emit: DeclarationEmit, + rootAbsolute: string, + subpaths: readonly ApiSubpath[], +): ReadonlyMap { + const files = new Map(); + for (const { subpath, entry, entryAbsolute } of subpaths) { + const emitted = emittedFileOf(emit, rootAbsolute, entryAbsolute); + if (!fs.existsSync(emitted)) { + throw new ApiInputError( + `${subpath}: entry ${entry} has no emitted declaration file (does the tsconfig include it?)`, + ); + } + files.set(subpath, emitted); + } + return files; +} diff --git a/packages/code-graph/src/api/map.ts b/packages/code-graph/src/api/map.ts new file mode 100644 index 00000000..352a052e --- /dev/null +++ b/packages/code-graph/src/api/map.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +// Every input the CLI refuses with exit 2 is one of these, carrying the message the CLI prints +// (SPEC §13.7). +export class ApiInputError extends Error { + override readonly name = "ApiInputError"; +} + +const ApiMapEntrySchema = z.strictObject({ + entry: z.string().min(1, "entry must be a non-empty path"), + tier: z.string().min(1, "tier must be a non-empty string").optional(), +}); + +// The caller's API map (SPEC §13.2): export subpath → its source entry, relative to the package +// root, and an optional opaque tier. Without its tiers it is #601's `SubpathEntries`. +export const ApiMapSchema = z + .record(z.string().min(1, "a subpath must be non-empty"), ApiMapEntrySchema) + .refine((map) => Object.keys(map).length > 0, "the API map names no subpath"); + +export type ApiMap = z.infer; + +const SOURCE_EXTENSION = /\.(?:ts|tsx|mts|cts)$/; +const DECLARATION_FILE = /\.d\.(?:ts|mts|cts)$/; + +// One subpath, checked against the package on disk: its entry is an absolute source file inside +// the package root. +export type ApiSubpath = { + readonly subpath: string; + readonly entry: string; + readonly entryAbsolute: string; + readonly tier: string | null; +}; + +// The first schema issue as `: `, the body of a refusal's one line. +export function issueText(error: z.ZodError): string { + const issue = error.issues[0]; + const where = issue?.path.join(".") || "(root)"; + return `${where}: ${issue?.message ?? "parse error"}`; +} + +function checkedSubpath(rootAbsolute: string, subpath: string, entry: string): string { + const entryAbsolute = path.resolve(rootAbsolute, entry); + const relative = path.relative(rootAbsolute, entryAbsolute); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new ApiInputError(`API map: ${subpath}: entry ${entry} lies outside ${rootAbsolute}`); + } + if (!SOURCE_EXTENSION.test(entry) || DECLARATION_FILE.test(entry)) { + throw new ApiInputError( + `API map: ${subpath}: entry ${entry} is not a .ts, .tsx, .mts or .cts source file`, + ); + } + if (!fs.existsSync(entryAbsolute) || !fs.statSync(entryAbsolute).isFile()) { + throw new ApiInputError(`API map: ${subpath}: entry ${entry} does not exist`); + } + return entryAbsolute; +} + +// Parses `map` through `ApiMapSchema` and checks every entry against the package, in subpath +// order; the first refusal throws `ApiInputError`. +export function apiSubpaths(rootAbsolute: string, map: unknown): readonly ApiSubpath[] { + const parsed = ApiMapSchema.safeParse(map); + if (!parsed.success) throw new ApiInputError(`invalid API map: ${issueText(parsed.error)}`); + return Object.keys(parsed.data) + .sort() + .map((subpath) => { + const { entry, tier } = parsed.data[subpath] as ApiMap[string]; + const entryAbsolute = checkedSubpath(rootAbsolute, subpath, entry); + return { subpath, entry, entryAbsolute, tier: tier ?? null }; + }); +} diff --git a/packages/code-graph/src/api/ratchet/changesets.ts b/packages/code-graph/src/api/ratchet/changesets.ts new file mode 100644 index 00000000..ea540ae3 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/changesets.ts @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import path from "node:path"; +import { findRepoRoot } from "../../extract/project.js"; +import { checkoutOf, namesAtBase, resolveBase } from "../base.js"; +import { ApiInputError } from "../map.js"; +import { packageRoot } from "../view.js"; +import type { Bump } from "./policy.js"; + +const CHANGESET_DIR = ".changeset"; + +// A bump a changeset can give a package and still count (SPEC §13.5): `none` releases nothing. +export type ChangesetBump = Exclude; + +// One counted changeset: its repo-relative file, the bump it gives the package, and its body. +export type Changeset = { + readonly file: string; + readonly bump: ChangesetBump; + readonly body: string; +}; + +// The package's changesets added since a base commit (SPEC §13.5). +export type ChangesetsSince = { + readonly package: string; + readonly changesets: readonly Changeset[]; +}; + +export type ChangesetsOptions = { readonly repoRoot?: string }; + +export type ChangesetText = { + readonly bumps: ReadonlyMap; + readonly body: string; +}; + +const BUMP_LINE = /^\s*(?:"([^"]+)"|'([^']+)'|([^\s:"']+))\s*:\s*(major|minor|patch|none)\s*$/; + +// The changesets format: a block between two `---` lines whose lines read `"": `, +// then the body. `null` when the text does not have that shape. +export function parseChangeset(text: string): ChangesetText | null { + const lines = text.replace(/^/, "").split(/\r?\n/); + const closing = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---"); + if (lines[0]?.trimEnd() !== "---" || closing === -1) return null; + const bumps = new Map(); + for (const line of lines.slice(1, closing)) { + if (line.trim() === "") continue; + const match = BUMP_LINE.exec(line); + if (match === null) return null; + bumps.set((match[1] ?? match[2] ?? match[3]) as string, match[4] as Bump); + } + return { bumps, body: lines.slice(closing + 1).join("\n") }; +} + +function packageName(rootAbsolute: string): string { + const file = path.join(rootAbsolute, "package.json"); + let name: unknown; + try { + name = (JSON.parse(fs.readFileSync(file, "utf8")) as { name?: unknown }).name; + } catch { + throw new ApiInputError(`cannot read ${file}; the ratchet needs the package's name`); + } + if (typeof name !== "string" || name === "") { + throw new ApiInputError(`${file} has no name; the ratchet needs the package's name`); + } + return name; +} + +// The `.md` files directly in the working tree's `.changeset/`, bar its README, sorted. +function changesetFilesNow(repoRoot: string): readonly string[] { + const dir = path.join(repoRoot, CHANGESET_DIR); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md") + .map((entry) => `${CHANGESET_DIR}/${entry.name}`) + .sort(); +} + +function countedChangeset(repoRoot: string, file: string, named: string): readonly Changeset[] { + const parsed = parseChangeset(fs.readFileSync(path.join(repoRoot, file), "utf8")); + if (parsed === null) { + throw new ApiInputError( + `changeset ${file} has no frontmatter that parses: expected a block between two --- lines whose lines read "": major | minor | patch | none`, + ); + } + const bump = parsed.bumps.get(named); + if (bump === undefined || bump === "none") return []; + return [{ file, bump, body: parsed.body }]; +} + +// The changesets that count for the package at `root` since the commit `base` names (SPEC §13.5): +// the `.md` files in the repo root's `.changeset/` that the working tree has, the base commit's +// tree lacks, and whose frontmatter gives the package a `major`, `minor` or `patch` bump. It only +// reads: the working tree's files, and `git ls-tree` on the base. +export function readChangesetsSince( + root: string, + base: string, + options: ChangesetsOptions = {}, +): ChangesetsSince { + const rootAbsolute = packageRoot(root); + const named = packageName(rootAbsolute); + const repoRoot = options.repoRoot ?? findRepoRoot(rootAbsolute); + const sha = resolveBase(checkoutOf(rootAbsolute), base); + const atBase = new Set(namesAtBase(repoRoot, sha, `${CHANGESET_DIR}/`)); + const changesets = changesetFilesNow(repoRoot) + .filter((file) => !atBase.has(file)) + .flatMap((file) => countedChangeset(repoRoot, file, named)); + return { package: named, changesets }; +} diff --git a/packages/code-graph/src/api/ratchet/gate.test.ts b/packages/code-graph/src/api/ratchet/gate.test.ts new file mode 100644 index 00000000..092c2c46 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/gate.test.ts @@ -0,0 +1,428 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { ApiRatchetVerdict } from "../../api.js"; +import { + bumped, + CALLOUT, + changeset, + checkoutBranch, + commitFiles, + git, + MAP_FILE, + PACKAGE_NAME, + POLICY_FILE, + type RatchetRepo, + ratchetRepository, + runCli, + runGate, +} from "../../test-helpers/ratchet-repo.js"; + +let repo: RatchetRepo; +let sha: string; +const scratch: string[] = []; + +beforeAll(() => { + repo = ratchetRepository(); + scratch.push(repo.top); + sha = repo.base.slice(0, 7); +}); + +afterAll(() => { + for (const dir of scratch) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const verdictOf = (stdout: string): ApiRatchetVerdict => JSON.parse(stdout) as ApiRatchetVerdict; + +// What a miss names, without its before/after text. +const named = (verdict: ApiRatchetVerdict) => + verdict.misses.map(({ subpath, tier, name, kind, needs }) => ({ + row: `${name} in ${subpath} (${tier}) ${kind}`, + needs, + })); + +function policyFile(policy: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-ratchet-policy-")); + scratch.push(dir); + const file = path.join(dir, "policy.json"); + fs.writeFileSync(file, JSON.stringify(policy)); + return file; +} + +const fixturePolicy = () => + JSON.parse(fs.readFileSync(POLICY_FILE, "utf8")) as { + callout: string; + tiers: Record; + default?: unknown; + }; + +describe("code-graph --api-policy — a CI job gates a PR on its caller's policy (SPEC §13.5)", () => { + it("fails a changed stable name until a minor changeset with the callout lands", () => { + checkoutBranch(repo, "stable-changed"); + const bare = runGate(repo); + expect(bare.code).toBe(1); + expect(bare.stdout).toBe( + [ + 'API RATCHET: parse in . (stable) changed — needs a minor changeset with a "**Breaking" callout; found none', + " before:", + " export declare function parse(text: string): number;", + " after:", + " export declare function parse(text: string, radix?: number): number;", + `API RATCHET: 1 of 1 change misses its bump against ${sha}`, + "", + ].join("\n"), + ); + expect(runGate(repo)).toEqual(bare); + + commitFiles(repo, { ".changeset/parse-radix.md": bumped("minor") }); + const quiet = runGate(repo); + expect(quiet.code).toBe(1); + expect(quiet.stdout).toContain( + 'API RATCHET: parse in . (stable) changed — needs a minor changeset with a "**Breaking" callout; found minor with no callout', + ); + expect(runGate(repo)).toEqual(quiet); + + commitFiles(repo, { ".changeset/parse-radix.md": bumped("minor", true) }); + const called = runGate(repo); + expect(called).toEqual({ + code: 0, + stdout: `API RATCHET: pass — 1 change against ${sha}, highest changeset bump minor, callout found\n`, + stderr: "", + }); + expect(runGate(repo)).toEqual(called); + }); + + // One branch per policy row. `below` is the strongest changeset that still misses the row + // (`null`: no changeset at all), `at` the weakest that meets it. + const rows = [ + { + branch: "stable-added", + row: "format in . (stable) added", + needs: { bump: "minor", callout: false }, + below: bumped("patch", true), + at: bumped("minor"), + }, + { + branch: "stable-changed", + row: "parse in . (stable) changed", + needs: { bump: "minor", callout: true }, + below: bumped("patch", true), + at: bumped("minor", true), + }, + { + branch: "stable-removed", + row: "legacy in . (stable) removed", + needs: { bump: "major", callout: true }, + below: bumped("minor", true), + at: bumped("major", true), + }, + { + branch: "battery-added", + row: "CAPACITY in ./battery (battery) added", + needs: { bump: "patch", callout: false }, + below: null, + at: bumped("patch"), + }, + { + branch: "battery-changed", + row: "charge in ./battery (battery) changed", + needs: { bump: "minor", callout: false }, + below: bumped("patch", true), + at: bumped("minor"), + }, + { + branch: "battery-removed", + row: "drain in ./battery (battery) removed", + needs: { bump: "minor", callout: true }, + below: bumped("major"), + at: bumped("minor", true), + }, + { + branch: "labs-changed", + row: "flag in ./labs (experimental) changed", + needs: { bump: "patch", callout: false }, + below: null, + at: bumped("patch"), + }, + { + branch: "labs-removed", + row: "trial in ./labs (experimental) removed", + needs: { bump: "patch", callout: true }, + below: bumped("major"), + at: bumped("patch", true), + }, + ] as const; + + it.each(rows)("holds the $branch branch to its row: $row", ({ + branch, + row, + needs, + below, + at, + }) => { + checkoutBranch(repo, branch, below === null ? {} : { "change.md": below }); + const missed = runGate(repo, ["--json"]); + expect(missed.code).toBe(1); + expect(named(verdictOf(missed.stdout))).toEqual([{ row, needs }]); + + checkoutBranch(repo, branch, { "change.md": at }); + const met = runGate(repo, ["--json"]); + expect(met.code).toBe(0); + expect(verdictOf(met.stdout)).toMatchObject({ passed: true, misses: [] }); + }); + + it("passes an added experimental name with no changeset, as its row asks for none", () => { + checkoutBranch(repo, "labs-added"); + expect(runGate(repo)).toEqual({ + code: 0, + stdout: `API RATCHET: pass — 1 change against ${sha}, highest changeset bump none, no callout\n`, + stderr: "", + }); + }); + + it("judges all nine rows of one branch at once, each against its own rule", () => { + const stable = [ + "format in . (stable) added", + "legacy in . (stable) removed", + "make in . (stable) changed", + ]; + const missesWith = (changesets: Readonly>): readonly string[] => { + checkoutBranch(repo, "every-row", changesets); + return named(verdictOf(runGate(repo, ["--json"]).stdout)).map(({ row }) => row); + }; + + expect(missesWith({})).toEqual([ + ...stable, + "CAPACITY in ./battery (battery) added", + "charge in ./battery (battery) changed", + "drain in ./battery (battery) removed", + "flag in ./labs (experimental) changed", + "trial in ./labs (experimental) removed", + ]); + expect(missesWith({ "a.md": bumped("patch") })).toEqual([ + ...stable, + "charge in ./battery (battery) changed", + "drain in ./battery (battery) removed", + "trial in ./labs (experimental) removed", + ]); + expect(missesWith({ "a.md": bumped("minor", true) })).toEqual(["legacy in . (stable) removed"]); + expect(missesWith({ "a.md": bumped("major", true) })).toEqual([]); + }); + + it("prints every miss with its before and after text, the same bytes on a second run", () => { + checkoutBranch(repo, "every-row", { "a.md": bumped("patch") }); + const text = runGate(repo); + expect(text.code).toBe(1); + expect(text.stdout).toContain( + [ + 'API RATCHET: make in . (stable) changed — needs a minor changeset with a "**Breaking" callout; found patch with no callout', + " before:", + " export declare function make(options: Options): {", + " options: Options;", + " };", + " src/index.d.ts#Options: type Options = {", + " readonly retries: number;", + " };", + " after:", + " export declare function make(options: Options): {", + " options: Options;", + " };", + " src/index.d.ts#Options: type Options = {", + " readonly retries: number;", + " readonly delayMs?: number;", + " };", + ].join("\n"), + ); + expect(text.stdout).toContain( + [ + "API RATCHET: format in . (stable) added — needs a minor changeset; found patch", + " after:", + " export declare function format(n: number): string;", + 'API RATCHET: legacy in . (stable) removed — needs a major changeset with a "**Breaking" callout; found patch with no callout', + " before:", + " export declare function legacy(): void;", + ].join("\n"), + ); + expect( + text.stdout.endsWith(`API RATCHET: 6 of 9 changes miss their bump against ${sha}\n`), + ).toBe(true); + expect(runGate(repo)).toEqual(text); + + const json = runGate(repo, ["--json", "--pretty"]); + expect(json.code).toBe(1); + expect(verdictOf(json.stdout)).toMatchObject({ + passed: false, + base: repo.base, + package: PACKAGE_NAME, + changesets: [".changeset/a.md"], + highestBump: "patch", + calloutFound: false, + }); + expect(runGate(repo, ["--json", "--pretty"])).toEqual(json); + }); + + it("passes a branch that changes no published name, with no changeset", () => { + checkoutBranch(repo, "no-api-change"); + expect(runGate(repo)).toEqual({ + code: 0, + stdout: `API RATCHET: pass — 0 changes against ${sha}, highest changeset bump none, no callout\n`, + stderr: "", + }); + expect(verdictOf(runGate(repo, ["--json"]).stdout)).toEqual({ + passed: true, + base: repo.base, + package: PACKAGE_NAME, + changesets: [], + highestBump: "none", + calloutFound: false, + misses: [], + }); + }); +}); + +describe("code-graph --api-policy — which changesets count", () => { + const verdictWith = (changesets: Readonly>): ApiRatchetVerdict => { + checkoutBranch(repo, "stable-added", changesets); + return verdictOf(runGate(repo, ["--json"]).stdout); + }; + + it("does not count a changeset for a different package", () => { + const verdict = verdictWith({ "other.md": changeset({ "@demo/other": "major" }, CALLOUT) }); + expect(verdict).toMatchObject({ + passed: false, + changesets: [], + highestBump: "none", + calloutFound: false, + }); + }); + + it("counts two changesets for the package as the higher of their bumps", () => { + const verdict = verdictWith({ "b-small.md": bumped("patch"), "a-large.md": bumped("minor") }); + expect(verdict).toMatchObject({ + passed: true, + changesets: [".changeset/a-large.md", ".changeset/b-small.md"], + highestBump: "minor", + }); + }); + + it("reads the package's own bump from a changeset that names several packages", () => { + const verdict = verdictWith({ + "both.md": changeset({ "@demo/other": "major", [PACKAGE_NAME]: "patch" }), + }); + expect(verdict).toMatchObject({ + passed: false, + changesets: [".changeset/both.md"], + highestBump: "patch", + }); + }); + + it("never counts a changeset the base commit already holds, nor the README", () => { + expect(fs.existsSync(path.join(repo.top, ".changeset", "shipped.md"))).toBe(true); + const verdict = verdictWith({}); + expect(verdict).toMatchObject({ changesets: [], highestBump: "none", calloutFound: false }); + }); + + it("counts a changeset the working tree holds but no commit does", () => { + checkoutBranch(repo, "stable-added"); + fs.writeFileSync(path.join(repo.top, ".changeset", "draft.md"), bumped("minor")); + const verdict = verdictOf(runGate(repo, ["--json"]).stdout); + fs.rmSync(path.join(repo.top, ".changeset", "draft.md")); + expect(verdict).toMatchObject({ passed: true, changesets: [".changeset/draft.md"] }); + }); + + it("exits 2 naming a changeset whose frontmatter does not parse", () => { + checkoutBranch(repo, "stable-added", { "broken.md": "no frontmatter here\n" }); + const refused = runGate(repo); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain( + "changeset .changeset/broken.md has no frontmatter that parses", + ); + }); +}); + +describe("code-graph --api-policy — refusals", () => { + it("exits 2 naming a tier the diff reports and the policy leaves out, rather than passing", () => { + const { callout, tiers } = fixturePolicy(); + const { battery: _battery, ...withoutBattery } = tiers; + const partial = policyFile({ callout, tiers: withoutBattery }); + + checkoutBranch(repo, "battery-changed", { "a.md": bumped("major", true) }); + const refused = runGate(repo, [], partial); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain( + 'bump policy: subpath ./battery has tier "battery", which the policy gives no row and no default', + ); + + // A tier the diff does not report needs no row: the same policy still gates a stable change. + checkoutBranch(repo, "stable-changed"); + expect(runGate(repo, [], partial).code).toBe(1); + }); + + it("judges an unnamed tier by the caller's default row when the policy states one", () => { + const { callout, tiers } = fixturePolicy(); + const { battery: _battery, ...withoutBattery } = tiers; + const row = { + added: { bump: "major" }, + changed: { bump: "major" }, + removed: { bump: "major" }, + }; + const defaulted = policyFile({ callout, tiers: withoutBattery, default: row }); + + checkoutBranch(repo, "battery-changed", { "a.md": bumped("minor") }); + const missed = runGate(repo, ["--json"], defaulted); + expect(missed.code).toBe(1); + expect(named(verdictOf(missed.stdout))).toEqual([ + { row: "charge in ./battery (battery) changed", needs: { bump: "major", callout: false } }, + ]); + }); + + it("exits 2 on a policy that fails the schema, before any emit", () => { + const refused = runGate(repo, [], policyFile({ callout: "x", tiers: {}, strict: true })); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain("invalid bump policy:"); + }); + + it("exits 2 on --api-policy without --api-base, and without --api", () => { + const noBase = runCli([repo.root, "--api", MAP_FILE, "--api-policy", POLICY_FILE]); + expect(noBase.code).toBe(2); + expect(noBase.stdout).toBe(""); + expect(noBase.stderr).toContain("--api-policy needs --api-base "); + + const noApi = runCli([repo.root, "--api-policy", POLICY_FILE]); + expect(noApi.code).toBe(2); + expect(noApi.stdout).toBe(""); + expect(noApi.stderr).toContain("--api-policy needs --api --api-base "); + }); + + it("exits 2 when the package has no name", () => { + checkoutBranch(repo, "stable-added"); + commitFiles(repo, { "packages/demo/package.json": '{ "version": "1.0.0" }\n' }); + const refused = runGate(repo); + expect(refused.code).toBe(2); + expect(refused.stdout).toBe(""); + expect(refused.stderr).toContain("package.json has no name"); + }); + + it("writes a miss to --out and still exits 1, leaving the checkout as it was", () => { + checkoutBranch(repo, "stable-removed"); + const state = () => ({ + status: git(repo.top, "--no-optional-locks", "status", "--porcelain=v1"), + index: git(repo.top, "ls-files", "--stage"), + head: git(repo.top, "rev-parse", "HEAD"), + branch: git(repo.top, "symbolic-ref", "HEAD"), + }); + const before = state(); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-ratchet-out-")); + scratch.push(outDir); + const out = path.join(outDir, "verdict.txt"); + const run = runGate(repo, ["--out", out]); + expect(run.code).toBe(1); + expect(run.stdout).toBe(""); + expect(fs.readFileSync(out, "utf8")).toContain("API RATCHET: legacy in . (stable) removed"); + expect(state()).toEqual(before); + }); +}); diff --git a/packages/code-graph/src/api/ratchet/policy.ts b/packages/code-graph/src/api/ratchet/policy.ts new file mode 100644 index 00000000..3707b0b1 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/policy.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { ApiInputError, issueText } from "../map.js"; + +// A changeset bump, weakest first; `none` is what a package with no counted changeset has. +export const BUMPS = ["none", "patch", "minor", "major"] as const; + +export type Bump = (typeof BUMPS)[number]; + +export const bumpRank = (bump: Bump): number => BUMPS.indexOf(bump); + +const BumpRuleSchema = z.strictObject({ + bump: z.enum(BUMPS), + callout: z.boolean().default(false), +}); + +const TierRowSchema = z.strictObject({ + added: BumpRuleSchema, + changed: BumpRuleSchema, + removed: BumpRuleSchema, +}); + +// The caller's bump policy (SPEC §13.5): per tier and change kind, the least changeset bump and +// whether a callout is owed, plus the callout's marker text. Every rule is the caller's: there is +// no built-in row, and a tier the policy does not name is judged only by the caller's own +// `default`. +export const BumpPolicySchema = z.strictObject({ + callout: z.string().min(1, "the callout marker must be non-empty"), + tiers: z.record(z.string().min(1, "a tier must be non-empty"), TierRowSchema), + default: TierRowSchema.optional(), +}); + +export type BumpPolicy = z.infer; +export type BumpRule = z.infer; +export type TierRow = z.infer; + +// Parses `policy` through `BumpPolicySchema`; the first refusal throws `ApiInputError`. +export function bumpPolicy(policy: unknown): BumpPolicy { + const parsed = BumpPolicySchema.safeParse(policy); + if (!parsed.success) { + throw new ApiInputError(`invalid bump policy: ${issueText(parsed.error)}`); + } + return parsed.data; +} diff --git a/packages/code-graph/src/api/ratchet/text.ts b/packages/code-graph/src/api/ratchet/text.ts new file mode 100644 index 00000000..2aed26d5 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/text.ts @@ -0,0 +1,63 @@ +import type { ApiEntryText } from "../read.js"; +import type { BumpRule } from "./policy.js"; +import type { ApiRatchetMiss, ApiRatchetVerdict } from "./verdict.js"; + +// What the human verdict says beyond the verdict itself: how many changes the diff reported, and +// the policy's callout marker. +export type VerdictContext = { readonly changes: number; readonly callout: string }; + +const SHORT_SHA = 7; + +const counted = (n: number): string => `${n} ${n === 1 ? "change" : "changes"}`; + +const indented = (text: string, by: string): string => + text + .split("\n") + .map((line) => `${by}${line}`) + .join("\n"); + +function entryLines(label: string, entry: ApiEntryText | null): readonly string[] { + if (entry === null) return []; + const references = Object.keys(entry.references) + .sort() + .map((key) => indented(`${key}: ${entry.references[key]}`, " ")); + return [` ${label}:`, indented(entry.text, " "), ...references]; +} + +function needText(needs: BumpRule, callout: string): string { + const bump = needs.bump === "none" ? "a changeset" : `a ${needs.bump} changeset`; + return needs.callout ? `${bump} with a ${JSON.stringify(callout)} callout` : bump; +} + +function foundText(verdict: ApiRatchetVerdict, needs: BumpRule): string { + if (verdict.highestBump === "none" || !needs.callout) return verdict.highestBump; + return `${verdict.highestBump} with ${verdict.calloutFound ? "the" : "no"} callout`; +} + +function missLines( + miss: ApiRatchetMiss, + verdict: ApiRatchetVerdict, + callout: string, +): readonly string[] { + const tier = miss.tier ?? "no tier"; + const head = `API RATCHET: ${miss.name} in ${miss.subpath} (${tier}) ${miss.kind}`; + return [ + `${head} — needs ${needText(miss.needs, callout)}; found ${foundText(verdict, miss.needs)}`, + ...entryLines("before", miss.before), + ...entryLines("after", miss.after), + ]; +} + +// The human verdict (SPEC §13.5): one line on a pass; on a miss, one block per missing change +// with its before and after text, then a summary line. +export function renderApiRatchet(verdict: ApiRatchetVerdict, context: VerdictContext): string { + const base = verdict.base.slice(0, SHORT_SHA); + if (verdict.passed) { + const callout = verdict.calloutFound ? "callout found" : "no callout"; + return `API RATCHET: pass — ${counted(context.changes)} against ${base}, highest changeset bump ${verdict.highestBump}, ${callout}\n`; + } + const blocks = verdict.misses.flatMap((miss) => missLines(miss, verdict, context.callout)); + const verb = verdict.misses.length === 1 ? "misses its" : "miss their"; + const summary = `API RATCHET: ${verdict.misses.length} of ${counted(context.changes)} ${verb} bump against ${base}`; + return `${[...blocks, summary].join("\n")}\n`; +} diff --git a/packages/code-graph/src/api/ratchet/verdict.test.ts b/packages/code-graph/src/api/ratchet/verdict.test.ts new file mode 100644 index 00000000..46d1e535 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/verdict.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { + type ApiDiff, + ApiInputError, + BumpPolicySchema, + type Changeset, + type ChangesetsSince, + ratchetApiDiff, +} from "../../api.js"; +import { parseChangeset } from "./changesets.js"; + +const text = (value: string) => ({ text: value, references: {} }); + +const subpath = (tier: string | null, names: Partial = {}) => ({ + tier, + added: {}, + removed: {}, + changed: {}, + ...names, +}); + +const diffOf = (subpaths: ApiDiff["subpaths"]): ApiDiff => ({ + root: "packages/demo", + base: "4b1c0de0000000000000000000000000000000aa", + compiler: "7.0.0", + subpaths, +}); + +const since = (...changesets: Changeset[]): ChangesetsSince => ({ + package: "@demo/pkg", + changesets, +}); + +const file = (bump: Changeset["bump"], body = "A change.", name = "a"): Changeset => ({ + file: `.changeset/${name}.md`, + bump, + body, +}); + +const row = (bump: string, callout = false) => ({ + added: { bump, callout }, + changed: { bump, callout }, + removed: { bump, callout }, +}); + +const policy = { callout: "**Breaking", tiers: { stable: row("minor", true), labs: row("none") } }; + +const changedStable = diffOf({ + ".": subpath("stable", { changed: { make: { before: text("a"), after: text("b") } } }), +}); + +describe("ratchetApiDiff — the verdict (SPEC §13.5)", () => { + it("passes a diff with no rows with no changeset, whatever the policy names", () => { + const empty = diffOf({ + ".": subpath("stable"), + "./other": subpath("unnamed"), + "./x": subpath(null), + }); + expect(ratchetApiDiff(empty, policy, since())).toEqual({ + passed: true, + base: empty.base, + package: "@demo/pkg", + changesets: [], + highestBump: "none", + calloutFound: false, + misses: [], + }); + }); + + it("misses a row whose bump is above the highest counted bump", () => { + const verdict = ratchetApiDiff(changedStable, policy, since(file("patch", "**Breaking: x"))); + expect(verdict.passed).toBe(false); + expect(verdict.misses).toEqual([ + { + subpath: ".", + tier: "stable", + name: "make", + kind: "changed", + needs: { bump: "minor", callout: true }, + before: text("a"), + after: text("b"), + }, + ]); + }); + + it("misses a row that asks for the callout when no counted body carries the marker", () => { + expect(ratchetApiDiff(changedStable, policy, since(file("major"))).passed).toBe(false); + expect(ratchetApiDiff(changedStable, policy, since(file("minor", "**Breaking"))).passed).toBe( + true, + ); + }); + + it("takes the higher of two bumps, and the callout from either file", () => { + const verdict = ratchetApiDiff( + changedStable, + policy, + since(file("patch", "**Breaking: x", "b"), file("minor", "A change.", "a")), + ); + expect(verdict).toMatchObject({ + passed: true, + highestBump: "minor", + calloutFound: true, + changesets: [".changeset/a.md", ".changeset/b.md"], + }); + }); + + it("gives an added row a null before and a removed row a null after, sorted by subpath then name", () => { + const diff = diffOf({ + "./b": subpath("stable", { removed: { gone: { before: text("g") } } }), + ".": subpath("stable", { + added: { zed: { after: text("z") } }, + changed: { alpha: { before: text("1"), after: text("2") } }, + }), + }); + const { misses } = ratchetApiDiff(diff, policy, since()); + expect(misses.map(({ subpath: at, name, kind }) => `${at} ${name} ${kind}`)).toEqual([ + ". alpha changed", + ". zed added", + "./b gone removed", + ]); + expect(misses[1]).toMatchObject({ before: null, after: text("z") }); + expect(misses[2]).toMatchObject({ before: text("g"), after: null }); + }); + + it("refuses a reported tier the policy does not name, naming the subpath and tier", () => { + const diff = diffOf({ "./beta": subpath("battery", { added: { x: { after: text("x") } } }) }); + expect(() => ratchetApiDiff(diff, policy, since(file("major", "**Breaking")))).toThrow( + new ApiInputError( + 'bump policy: subpath ./beta has tier "battery", which the policy gives no row and no default', + ), + ); + }); + + it("refuses a reported subpath with no tier when the policy has no default", () => { + const diff = diffOf({ "./raw": subpath(null, { added: { x: { after: text("x") } } }) }); + expect(() => ratchetApiDiff(diff, policy, since())).toThrow( + new ApiInputError( + "bump policy: subpath ./raw has no tier, which the policy gives no row and no default", + ), + ); + const defaulted = { ...policy, default: row("patch") }; + expect(ratchetApiDiff(diff, defaulted, since()).misses[0]).toMatchObject({ + tier: null, + needs: { bump: "patch", callout: false }, + }); + expect(ratchetApiDiff(diff, defaulted, since(file("patch"))).passed).toBe(true); + }); + + it("does not read a tier named like an Object.prototype member as a policy row", () => { + const diff = diffOf({ ".": subpath("constructor", { added: { x: { after: text("x") } } }) }); + expect(() => ratchetApiDiff(diff, policy, since())).toThrow(ApiInputError); + }); + + it("refuses an invalid policy", () => { + expect(() => ratchetApiDiff(changedStable, { tiers: {} }, since())).toThrow(ApiInputError); + }); +}); + +describe("BumpPolicySchema", () => { + it("defaults a rule's callout to false and keeps the caller's default row", () => { + const parsed = BumpPolicySchema.parse({ + callout: "!", + tiers: { + stable: { + added: { bump: "minor" }, + changed: { bump: "major" }, + removed: { bump: "major", callout: true }, + }, + }, + }); + expect(parsed.tiers.stable?.added).toEqual({ bump: "minor", callout: false }); + expect(parsed.default).toBeUndefined(); + }); + + it.each([ + ["an unknown key", { callout: "!", tiers: {}, extra: 1 }], + ["an empty marker", { callout: "", tiers: {} }], + ["a row missing a change kind", { callout: "!", tiers: { s: { added: { bump: "none" } } } }], + ["an unknown bump", { callout: "!", tiers: { s: row("huge") } }], + [ + "an unknown rule key", + { callout: "!", tiers: { s: { ...row("none"), added: { bump: "none", note: 1 } } } }, + ], + ])("refuses %s", (_what, value) => { + expect(BumpPolicySchema.safeParse(value).success).toBe(false); + }); +}); + +describe("parseChangeset — the changesets frontmatter", () => { + it("reads each package's bump and the body after the closing line", () => { + const parsed = parseChangeset( + "---\n\"@demo/pkg\": minor\n'@demo/other': patch\nbare: none\n---\n\nBody\n---\nmore\n", + ); + expect([...(parsed?.bumps ?? [])]).toEqual([ + ["@demo/pkg", "minor"], + ["@demo/other", "patch"], + ["bare", "none"], + ]); + expect(parsed?.body).toBe("\nBody\n---\nmore\n"); + }); + + it("reads an empty changeset and CRLF line ends", () => { + expect(parseChangeset("---\n---\n")?.bumps.size).toBe(0); + expect(parseChangeset('---\r\n"a": major\r\n---\r\nBody\r\n')?.bumps.get("a")).toBe("major"); + }); + + it.each([ + ["no opening line", '"a": minor\n---\n'], + ["no closing line", '---\n"a": minor\n'], + ["a bump that is not one", '---\n"a": huge\n---\n'], + ["a line that is not a bump", "---\nnot a bump line\n---\n"], + ])("gives null for %s", (_what, value) => { + expect(parseChangeset(value)).toBeNull(); + }); +}); diff --git a/packages/code-graph/src/api/ratchet/verdict.ts b/packages/code-graph/src/api/ratchet/verdict.ts new file mode 100644 index 00000000..92d5e711 --- /dev/null +++ b/packages/code-graph/src/api/ratchet/verdict.ts @@ -0,0 +1,105 @@ +import type { ApiDiff, SubpathDiff } from "../diff.js"; +import { ApiInputError } from "../map.js"; +import type { ApiEntryText } from "../read.js"; +import type { ChangesetsSince } from "./changesets.js"; +import { type Bump, type BumpPolicy, type BumpRule, bumpPolicy, bumpRank } from "./policy.js"; + +export type ChangeKind = "added" | "changed" | "removed"; + +// One name the diff reports: `before` is null for an added name, `after` for a removed one. +export type ApiChange = { + readonly subpath: string; + readonly tier: string | null; + readonly name: string; + readonly kind: ChangeKind; + readonly before: ApiEntryText | null; + readonly after: ApiEntryText | null; +}; + +// A change whose rule the package's changesets do not meet, with the rule it needed. +export type ApiRatchetMiss = ApiChange & { readonly needs: BumpRule }; + +// The ratchet's answer (SPEC §13.5): which changes miss the bump the caller's policy asks of them. +export type ApiRatchetVerdict = { + readonly passed: boolean; + readonly base: string; + readonly package: string; + readonly changesets: readonly string[]; + readonly highestBump: Bump; + readonly calloutFound: boolean; + readonly misses: readonly ApiRatchetMiss[]; +}; + +const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +function subpathChanges(subpath: string, diff: SubpathDiff): readonly ApiChange[] { + const at = { subpath, tier: diff.tier }; + return [ + ...Object.entries(diff.added).map( + ([name, { after }]): ApiChange => ({ ...at, name, kind: "added", before: null, after }), + ), + ...Object.entries(diff.changed).map( + ([name, { before, after }]): ApiChange => ({ ...at, name, kind: "changed", before, after }), + ), + ...Object.entries(diff.removed).map( + ([name, { before }]): ApiChange => ({ ...at, name, kind: "removed", before, after: null }), + ), + ]; +} + +// Every name the diff reports, sorted by subpath, then name, then kind. +export function apiChanges(diff: ApiDiff): readonly ApiChange[] { + return Object.keys(diff.subpaths) + .sort(byText) + .flatMap((subpath) => + [...subpathChanges(subpath, diff.subpaths[subpath] as SubpathDiff)].sort( + (a, b) => byText(a.name, b.name) || byText(a.kind, b.kind), + ), + ); +} + +// The policy's rule for one change: its tier's row, else the caller's `default`. A change no row +// covers is refused, so nothing passes unpoliced. +function ruleFor(policy: BumpPolicy, change: ApiChange): BumpRule { + const { tier, subpath } = change; + const row = + tier !== null && Object.hasOwn(policy.tiers, tier) ? policy.tiers[tier] : policy.default; + if (row !== undefined) return row[change.kind]; + const tierNamed = tier === null ? "no tier" : `tier "${tier}"`; + throw new ApiInputError( + `bump policy: subpath ${subpath} has ${tierNamed}, which the policy gives no row and no default`, + ); +} + +// Judges `diff` against the caller's `policy` and the package's counted `changesets` (SPEC §13.5). +// Pure: it reads nothing but its arguments. A change misses when its rule's bump is above the +// highest counted bump, or when its rule asks for the callout and no counted changeset's body +// carries the policy's marker. Throws `ApiInputError` on an invalid policy, or on a change whose +// tier the policy does not cover. +export function ratchetApiDiff( + diff: ApiDiff, + policy: unknown, + changesets: ChangesetsSince, +): ApiRatchetVerdict { + const rules = bumpPolicy(policy); + const counted = changesets.changesets; + const highestBump = counted.reduce( + (highest, { bump }) => (bumpRank(bump) > bumpRank(highest) ? bump : highest), + "none", + ); + const calloutFound = counted.some(({ body }) => body.includes(rules.callout)); + const misses = apiChanges(diff).flatMap((change): ApiRatchetMiss[] => { + const needs = ruleFor(rules, change); + const met = bumpRank(needs.bump) <= bumpRank(highestBump) && (!needs.callout || calloutFound); + return met ? [] : [{ ...change, needs }]; + }); + return { + passed: misses.length === 0, + base: diff.base, + package: changesets.package, + changesets: counted.map(({ file }) => file).sort(byText), + highestBump, + calloutFound, + misses, + }; +} diff --git a/packages/code-graph/src/api/read.ts b/packages/code-graph/src/api/read.ts new file mode 100644 index 00000000..a402fb78 --- /dev/null +++ b/packages/code-graph/src/api/read.ts @@ -0,0 +1,181 @@ +import path from "node:path"; +import * as ts from "../engine/tsgo.js"; +import { moduleSymbolOf } from "../resolve.js"; +import { declarationText, statementOf } from "./text.js"; + +// A published name as a consumer's types see it (SPEC §13.3): its own declaration text, plus the +// text of every non-published declaration in the emitted tree it reaches, keyed +// `#`. +export type ApiEntryText = { + readonly text: string; + readonly references: Readonly>; +}; + +export type SubpathNames = Readonly>; + +// A name's own declarations, followed through aliases, renames and `export *`. +type Published = { readonly name: string; readonly declarations: readonly ts.Node[] }; + +// A file's first statement starts where the file does, so the kind keeps the two apart. +const nodeKey = (node: ts.Node): string => + `${node.getSourceFile().fileName}:${node.pos}:${node.end}:${node.kind}`; + +function byFileThenPosition(a: ts.Node, b: ts.Node): number { + const fileA = a.getSourceFile().fileName; + const fileB = b.getSourceFile().fileName; + if (fileA !== fileB) return fileA < fileB ? -1 : 1; + return a.pos - b.pos; +} + +// Several declarations of one name (overloads, merges) in emitted file order, then position; a +// variable statement declaring several names is one statement. +function statementsOf(declarations: readonly ts.Node[]): readonly ts.Node[] { + const statements = new Map(); + for (const declaration of declarations) { + const statement = statementOf(declaration); + statements.set(nodeKey(statement), statement); + } + return [...statements.values()].sort(byFileThenPosition); +} + +const joinedText = (statements: readonly ts.Node[]): string => + statements.map(declarationText).join("\n"); + +function publishedNames(program: ts.TypeProgram, entryFile: string): readonly Published[] { + const moduleSymbol = moduleSymbolOf(program, entryFile); + if (moduleSymbol === undefined) return []; + return [...program.exportsOf(moduleSymbol)].map(([name, symbol]) => ({ + name, + declarations: statementsOf(program.declarations(program.aliasTarget(symbol) ?? symbol)), + })); +} + +const entityName = (name: ts.Node | undefined): ts.Node | undefined => + name !== undefined && ts.isQualifiedName(name) ? name.right : name; + +const expressionName = (expression: ts.Node): ts.Node => + ts.isPropertyAccessExpression(expression) ? expression.name : expression; + +// The node a reference names, for every kind of reference an emitted declaration spells out: +// `Foo`, `A.B`, `extends Base`, `typeof value`, `import("./x").Foo`, and a computed member key +// such as `[Brand]`, whose `unique symbol` is part of the type that carries it. +function referenceName(node: ts.Node): ts.Node | undefined { + if (ts.isTypeReferenceNode(node)) return entityName(node.typeName); + if (ts.isTypeQueryNode(node)) return entityName(node.exprName); + if (ts.isImportTypeNode(node)) return entityName(node.qualifier); + if (ts.isExpressionWithTypeArguments(node) || ts.isComputedPropertyName(node)) { + return expressionName(node.expression); + } + return undefined; +} + +function referenceNames(statement: ts.Node): readonly ts.Node[] { + const found: ts.Node[] = []; + const visit = (node: ts.Node): undefined => { + const name = referenceName(node); + if (name !== undefined) found.push(name); + node.forEachChild(visit); + return undefined; + }; + visit(statement); + return found; +} + +const TOP_LEVEL_DECLARATION: ReadonlyArray<(node: ts.Node) => boolean> = [ + ts.isTypeAliasDeclaration, + ts.isInterfaceDeclaration, + ts.isClassDeclaration, + ts.isEnumDeclaration, + ts.isFunctionDeclaration, + ts.isModuleDeclaration, + ts.isVariableDeclaration, +]; + +function declaredName(declaration: ts.Node): string { + const name = (declaration as { name?: ts.Node }).name; + return name !== undefined && ts.isIdentifier(name) ? name.text : "default"; +} + +// Walks from a published name's declarations to every declaration it reaches that sits in the +// emitted tree and that its subpath does not publish, under a statement or a namespace body. +class ReferenceWalk { + readonly #references = new Map>(); + readonly #walked = new Set(); + + constructor( + private readonly program: ts.TypeProgram, + private readonly out: string, + private readonly published: ReadonlySet, + ) {} + + from(statements: readonly ts.Node[]): Record { + const queue = [...statements]; + for (const statement of statements) this.#walked.add(nodeKey(statement)); + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + queue.push(...this.#step(next)); + } + return this.#texts(); + } + + #step(statement: ts.Node): readonly ts.Node[] { + const names = referenceNames(statement); + const reached: ts.Node[] = []; + for (const symbol of this.program.symbolsAt(names)) { + if (symbol === undefined) continue; + const target = this.program.aliasTarget(symbol) ?? symbol; + for (const declaration of this.program.declarations(target)) { + const statement = this.#unpublished(declaration); + if (statement !== null) reached.push(...this.#record(declaration, statement)); + } + } + return reached; + } + + #unpublished(declaration: ts.Node): ts.Node | null { + if (!TOP_LEVEL_DECLARATION.some((is) => is(declaration))) return null; + if (!declaration.getSourceFile().fileName.startsWith(`${this.out}${path.sep}`)) return null; + const statement = statementOf(declaration); + const holder = statement.parent; + if (!ts.isSourceFile(holder) && !ts.isModuleBlock(holder)) return null; + for (let at: ts.Node | undefined = statement; at !== undefined; at = at.parent) { + if (this.published.has(nodeKey(at))) return null; + if (ts.isSourceFile(at)) break; + } + return statement; + } + + #record(declaration: ts.Node, statement: ts.Node): readonly ts.Node[] { + const file = path.relative(this.out, declaration.getSourceFile().fileName).split(path.sep); + const key = `${file.join("/")}#${declaredName(declaration)}`; + const statements = this.#references.get(key) ?? new Map(); + statements.set(nodeKey(statement), statement); + this.#references.set(key, statements); + if (this.#walked.has(nodeKey(statement))) return []; + this.#walked.add(nodeKey(statement)); + return [statement]; + } + + #texts(): Record { + const texts: Record = {}; + for (const [key, statements] of this.#references) { + texts[key] = joinedText([...statements.values()].sort(byFileThenPosition)); + } + return texts; + } +} + +// Every name the module at `entryFile` publishes, with its text and references. +export function readSubpathNames( + program: ts.TypeProgram, + out: string, + entryFile: string, +): SubpathNames { + const names = publishedNames(program, entryFile); + const published = new Set(names.flatMap((name) => name.declarations.map(nodeKey))); + const read: Record = {}; + for (const { name, declarations } of names) { + const references = new ReferenceWalk(program, out, published).from(declarations); + read[name] = { text: joinedText(declarations), references }; + } + return read; +} diff --git a/packages/code-graph/src/api/text.test.ts b/packages/code-graph/src/api/text.test.ts new file mode 100644 index 00000000..69113521 --- /dev/null +++ b/packages/code-graph/src/api/text.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { tidyLines, withoutComments } from "./text.js"; + +// A template literal type's substitution, spelled without a template placeholder in this file. +const hole = (inner: string): string => `$${"{"}${inner}}`; + +describe("withoutComments", () => { + it("drops line and block comments and keeps everything else as written", () => { + const text = [ + "export interface Shape {", + " /** The side. */", + " readonly side: number; // trailing", + " readonly /* inline */ corner: Corner;", + "}", + ].join("\n"); + expect(tidyLines(withoutComments(text))).toBe( + [ + "export interface Shape {", + " readonly side: number;", + " readonly corner: Corner;", + "}", + ].join("\n"), + ); + }); + + it("keeps comment-like text inside strings and template literal types", () => { + const url = `type Url = \`https://${hole("Host")}/${hole('"/* x */"')}\`;`; + const text = `${url} // gone\ntype S = "// kept";`; + expect(withoutComments(text)).toBe(`${url} \ntype S = "// kept";`); + }); + + it("reads braces inside a template literal type's substitution", () => { + const type = `type T = \`a${hole('{ b: 1 }["b"]')}c\``; + expect(withoutComments(`${type} /* gone */;`)).toBe(`${type} ;`); + }); +}); diff --git a/packages/code-graph/src/api/text.ts b/packages/code-graph/src/api/text.ts new file mode 100644 index 00000000..00ebcb7d --- /dev/null +++ b/packages/code-graph/src/api/text.ts @@ -0,0 +1,61 @@ +import * as ts from "../engine/tsgo.js"; + +// The text rule of SPEC §13.3: a declaration's emitted text from its first token to the end of its +// statement, with every comment removed, trailing whitespace trimmed, blank lines dropped and +// indentation kept as emitted. + +const COMMENT: ReadonlySet = new Set([ + ts.SyntaxKind.SingleLineCommentTrivia, + ts.SyntaxKind.MultiLineCommentTrivia, +]); + +// Template literal types close a substitution with `}`, which the scanner reads as a brace until +// told otherwise; `holes` holds the brace depth each open substitution started at. +type TemplateState = { depth: number; readonly holes: number[] }; + +function nextToken(scanner: ts.Scanner, state: TemplateState): ts.SyntaxKind { + const kind = scanner.scan(); + if (kind === ts.SyntaxKind.TemplateHead) state.holes.push(state.depth); + if (kind === ts.SyntaxKind.OpenBraceToken) state.depth++; + if (kind !== ts.SyntaxKind.CloseBraceToken) return kind; + if (state.holes.at(-1) !== state.depth) { + state.depth--; + return kind; + } + const rescanned = scanner.reScanTemplateToken(false); + if (rescanned === ts.SyntaxKind.TemplateTail) state.holes.pop(); + return rescanned; +} + +export function withoutComments(text: string): string { + const scanner = ts.createScanner(false, ts.LanguageVariant.Standard, text); + const state: TemplateState = { depth: 0, holes: [] }; + let out = ""; + for (let kind = nextToken(scanner, state); kind !== ts.SyntaxKind.EndOfFile; ) { + if (!COMMENT.has(kind)) out += scanner.getTokenText(); + kind = nextToken(scanner, state); + } + return out; +} + +export function tidyLines(text: string): string { + return text + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line !== "") + .join("\n"); +} + +// The statement a declaration stands in: a variable's whole variable statement, every other +// declaration itself. +export function statementOf(declaration: ts.Node): ts.Node { + if (!ts.isVariableDeclaration(declaration)) return declaration; + const list = declaration.parent; + return ts.isVariableStatement(list.parent) ? list.parent : declaration; +} + +export function declarationText(statement: ts.Node): string { + const source = statement.getSourceFile(); + const start = ts.isSourceFile(statement) ? 0 : statement.getStart(source); + return tidyLines(withoutComments(source.text.slice(start, statement.end))); +} diff --git a/packages/code-graph/src/api/view.ts b/packages/code-graph/src/api/view.ts new file mode 100644 index 00000000..34049052 --- /dev/null +++ b/packages/code-graph/src/api/view.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as ts from "../engine/tsgo.js"; +import { findRepoRoot, resolveEdgeTsConfig } from "../extract/project.js"; +import { emitDeclarations, emittedEntries, removeEmit } from "./emit.js"; +import { ApiInputError, type ApiSubpath, apiSubpaths } from "./map.js"; +import { readSubpathNames, type SubpathNames } from "./read.js"; + +export type PublishedApi = { + readonly root: string; + readonly compiler: string; + readonly subpaths: Readonly< + Record< + string, + { readonly entry: string; readonly tier: string | null; readonly names: SubpathNames } + > + >; +}; + +export type PublishedApiOptions = { + readonly repoRoot?: string; + // Where the emit's diagnostic count goes as one warning line; stderr by default. + readonly warn?: (line: string) => void; +}; + +export function packageRoot(root: string): string { + const absolute = path.resolve(root); + if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory()) { + throw new ApiInputError(`${absolute} is not a directory`); + } + return fs.realpathSync(absolute); +} + +function packageTsConfig(rootAbsolute: string, repoRoot: string): string { + try { + return resolveEdgeTsConfig(rootAbsolute, "package", repoRoot); + } catch (error) { + throw new ApiInputError(error instanceof Error ? error.message : String(error)); + } +} + +function readNames( + tsConfigPath: string, + out: string, + entries: ReadonlyMap, +): ReadonlyMap { + const program = ts.openTypeProgram({ + tsConfigPath, + rootFiles: [...new Set(entries.values())].sort(), + includeConfigFiles: false, + }); + try { + return new Map( + [...entries].map(([subpath, file]) => [subpath, readSubpathNames(program, out, file)]), + ); + } finally { + program.close(); + } +} + +// What one emit of one tree publishes: the compiler that emitted, and the names per subpath. +export type EmittedNames = { + readonly compiler: string; + readonly names: ReadonlyMap; +}; + +export const projectRelative = (rootAbsolute: string): string => + path.relative(process.cwd(), rootAbsolute) || "."; + +// Emits the package at `rootAbsolute` and reads every subpath in `subpaths`, whose entries are +// already checked against that tree. The emit lives in a temp folder outside the tree and is +// removed before this returns or throws. `context` names the tree in the diagnostics warning. +export async function readEmittedNames( + rootAbsolute: string, + subpaths: readonly ApiSubpath[], + options: PublishedApiOptions & { readonly context?: string }, +): Promise { + const tsConfigPath = packageTsConfig( + rootAbsolute, + options.repoRoot ?? findRepoRoot(rootAbsolute), + ); + const emit = await emitDeclarations(rootAbsolute, tsConfigPath); + try { + if (emit.diagnostics > 0) { + const warn = options.warn ?? ((line: string) => process.stderr.write(`${line}\n`)); + warn( + `code-graph: warning: tsgo reported ${emit.diagnostics} diagnostic(s)${options.context ?? ""}; the declarations were emitted anyway`, + ); + } + const entries = emittedEntries(emit, rootAbsolute, subpaths); + return { compiler: emit.compiler, names: readNames(tsConfigPath, emit.out, entries) }; + } finally { + removeEmit(emit); + } +} + +// The published-API view of the package at `root` (SPEC §13.3): every name each subpath of `map` +// publishes, with its declaration text as emitted. The emit lives in a temp folder outside the +// checkout and is removed before this returns or throws. +export async function readPublishedApi( + root: string, + map: unknown, + options: PublishedApiOptions = {}, +): Promise { + const rootAbsolute = packageRoot(root); + const subpaths = apiSubpaths(rootAbsolute, map); + const { compiler, names } = await readEmittedNames(rootAbsolute, subpaths, options); + const view: Record = {}; + for (const { subpath, entry, tier } of subpaths) { + view[subpath] = { entry, tier, names: names.get(subpath) ?? {} }; + } + return { root: projectRelative(rootAbsolute), compiler, subpaths: view }; +} diff --git a/packages/code-graph/src/cli.ts b/packages/code-graph/src/cli.ts index 5e9038aa..8a452622 100644 --- a/packages/code-graph/src/cli.ts +++ b/packages/code-graph/src/cli.ts @@ -47,6 +47,9 @@ export type Opts = { acceptCrossings?: boolean; reason?: string; migrateCeilings?: boolean; + api?: string; + apiBase?: string; + apiPolicy?: string; }; export function cleanExit(message: string): void { @@ -54,6 +57,11 @@ export function cleanExit(message: string): void { process.exitCode = 2; } +// Sets the process exit code a run resolved to; 0 leaves whatever is already set. +export function exitWith(code: number): void { + if (code !== 0) process.exitCode = code; +} + function graphOptions(command: Command): Command { return command .option("--graph", "emit the full Graph JSON (the large dump)") @@ -161,6 +169,22 @@ function featureOptions(command: Command): Command { ); } +function apiOptions(command: Command): Command { + return command + .option( + "--api ", + 'published-API view (SPEC §13): emit \'s declarations with the pinned tsgo into a temp folder and print, per export subpath of the JSON map ({ "": { "entry": "", "tier"?: "" } }), every published name with its declaration text and the text of the unpublished declarations it references; combines only with --api-base, --api-policy, --json, --pretty and --out', + ) + .option( + "--api-policy ", + 'with --api --api-base: gate the diff on the package\'s changesets (SPEC §13.5). is the caller\'s JSON bump policy ({ "callout": "", "tiers": { "": { "added" | "changed" | "removed": { "bump": "none" | "patch" | "minor" | "major", "callout"?: true } } }, "default"?: }); code-graph ships no policy of its own. The changesets that count are the .changeset/*.md files added since whose frontmatter names the package of /package.json; the highest of their bumps is compared with the bump each changed name\'s tier and change kind needs, and a rule with a callout also needs the marker in a counted changeset\'s body. Prints one line on a pass, or one block per name that misses (name, subpath, tier, change kind, bump needed and found, before/after text); --json prints the verdict. Exits 0 on a pass, 1 on a miss, 2 on a tier with no policy row and no default', + ) + .option( + "--api-base ", + "with --api: diff the published API against the commit names (SPEC §13.4) and print, per subpath, the added, removed and changed names with before/after text and tier; the base tree is read from git's objects into a temp folder, never into the checkout", + ); +} + function outputOptions(command: Command): Command { return command .option("--pretty", "pretty-print JSON output") @@ -176,5 +200,5 @@ export function defineProgram(): Command { .name("code-graph") .description("Agent-native TypeScript code-graph tool (SPEC.md)") .argument("", "folder to analyze"); - return outputOptions(featureOptions(analysisOptions(graphOptions(command)))); + return outputOptions(apiOptions(featureOptions(analysisOptions(graphOptions(command))))); } diff --git a/packages/code-graph/src/index.ts b/packages/code-graph/src/index.ts index 81c357c9..b1846100 100644 --- a/packages/code-graph/src/index.ts +++ b/packages/code-graph/src/index.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import type { Command } from "commander"; +import { runApiFlags } from "./api/cli.js"; import { runBoundaryGate } from "./boundaries/gate.js"; -import { cleanExit, defineProgram, type Opts } from "./cli.js"; +import { cleanExit, defineProgram, exitWith, type Opts } from "./cli.js"; import { runCollapseGate } from "./collapse/gate.js"; import { renderCollapse } from "./collapse/render.js"; import { runCommentGate } from "./comments/gate.js"; @@ -55,7 +57,7 @@ import type { Graph } from "./schema.js"; import { isPlanAxis, type PlanAxis } from "./smells/plan.js"; defineProgram() - .action((targetPath: string, opts: Opts) => { + .action((targetPath: string, opts: Opts, command: Command) => { const resolved = path.resolve(targetPath); let stat: fs.Stats; @@ -94,6 +96,19 @@ defineProgram() process.stdout.write(payload); }; + const apiRun = runApiFlags(opts, { + rootAbsolute, + given: Object.keys(opts).filter((key) => command.getOptionValueSource(key) === "cli"), + json, + pretty, + emit, + report: cleanExit, + }); + if (apiRun !== null) { + void apiRun.then(exitWith); + return; + } + if (opts.layers === true) { const code = runLayerGate({ rootAbsolute, diff --git a/packages/code-graph/src/resolve.ts b/packages/code-graph/src/resolve.ts index 16d1b2d1..5765b4c9 100644 --- a/packages/code-graph/src/resolve.ts +++ b/packages/code-graph/src/resolve.ts @@ -99,14 +99,23 @@ function exportOrigin( return { file: decl.getSourceFile().fileName, name: declaredName(decl) ?? exportName }; } +// The file-to-module-symbol step: the module symbol of `moduleFile` in `program`, or undefined when +// the program holds no such file or the file is not a module. It is the package's one such step; +// the published-API view (`src/api/`) asks it too. +export function moduleSymbolOf( + program: ts.TypeProgram, + moduleFile: string, +): ts.TsSymbol | undefined { + const source = program.sourceFile(moduleFile); + return source === undefined ? undefined : program.symbolAt(source); +} + function moduleExport( program: ts.TypeProgram, moduleFile: string, exportName: string, ): ExportDeclaration | null { - const source = program.sourceFile(moduleFile); - if (source === undefined) return null; - const moduleSymbol = program.symbolAt(source); + const moduleSymbol = moduleSymbolOf(program, moduleFile); if (moduleSymbol === undefined) return null; const decl = exportedDeclaration(program, moduleSymbol, exportName); if (decl === undefined) return null; diff --git a/packages/code-graph/src/test-helpers/ratchet-repo.ts b/packages/code-graph/src/test-helpers/ratchet-repo.ts new file mode 100644 index 00000000..c2457f19 --- /dev/null +++ b/packages/code-graph/src/test-helpers/ratchet-repo.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// The committed ratchet fixture (test/api/ratchet): the package `@demo/pkg` with a `stable`, a +// `battery` and an `experimental` subpath (`base/`), one overlay per branch (`branches//`, +// holding only the files that branch rewrites), the API map and a bump policy with a row for every +// tier and change kind. +const here = path.dirname(fileURLToPath(import.meta.url)); +export const PACKAGE_DIR = path.resolve(here, "..", ".."); +export const FIXTURE = path.join(PACKAGE_DIR, "test", "api", "ratchet"); +export const MAP_FILE = path.join(FIXTURE, "api-map.json"); +export const POLICY_FILE = path.join(FIXTURE, "policy.json"); +const CLI = path.join(PACKAGE_DIR, "src", "index.ts"); + +export const PACKAGE_NAME = "@demo/pkg"; +export const CALLOUT = "**Breaking:** callers must change."; + +export const git = (cwd: string, ...args: string[]): string => + execFileSync( + "git", + ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", ...args], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + +// A changeset file's text: one frontmatter line per package, then the body. +export const changeset = (bumps: Readonly>, body = "A change."): string => + `---\n${Object.entries(bumps) + .map(([name, bump]) => `"${name}": ${bump}\n`) + .join("")}---\n\n${body}\n`; + +// A changeset for the fixture package; `callout` puts the policy's marker in its body. +export const bumped = (bump: string, callout = false): string => + changeset({ [PACKAGE_NAME]: bump }, callout ? CALLOUT : "A change."); + +export type RatchetRepo = { + readonly top: string; + // The package root, `packages/demo` below the repository's top. + readonly root: string; + // The base commit every branch is cut from: `main`'s sha. + readonly base: string; +}; + +// A throwaway repository with the fixture's base committed on `main`: a pnpm workspace whose +// `.changeset/` already holds a README and one shipped changeset for the package (a `major` with +// the callout), which predate every branch and so must never count. +export function ratchetRepository(): RatchetRepo { + const top = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-ratchet-"))); + const root = path.join(top, "packages", "demo"); + git(top, "init", "--quiet", "--initial-branch=main"); + fs.writeFileSync(path.join(top, "pnpm-workspace.yaml"), 'packages:\n - "packages/*"\n'); + fs.mkdirSync(path.join(top, ".changeset")); + fs.writeFileSync(path.join(top, ".changeset", "README.md"), "# Changesets\n"); + fs.writeFileSync(path.join(top, ".changeset", "shipped.md"), bumped("major", true)); + fs.cpSync(path.join(FIXTURE, "base"), root, { recursive: true }); + git(top, "add", "-A"); + git(top, "commit", "--quiet", "--no-gpg-sign", "-m", "base"); + return { top, root, base: git(top, "rev-parse", "HEAD").trim() }; +} + +// Commits `files` (repo-relative path → text) on the branch the repository is on. +export function commitFiles(repo: RatchetRepo, files: Readonly>): void { + for (const [relative, text] of Object.entries(files)) { + const file = path.join(repo.top, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + } + git(repo.top, "add", "-A"); + git(repo.top, "commit", "--quiet", "--no-gpg-sign", "--allow-empty", "-m", "files"); +} + +// Cuts the fixture branch `name` off `main` afresh, as a PR would: its overlay committed over the +// base, then `changesets` (file name → text) committed into `.changeset/`. +export function checkoutBranch( + repo: RatchetRepo, + name: string, + changesets: Readonly> = {}, +): void { + git(repo.top, "checkout", "--quiet", "-B", name, "main"); + fs.cpSync(path.join(FIXTURE, "branches", name), repo.root, { recursive: true }); + const files = Object.entries(changesets).map(([file, text]) => [`.changeset/${file}`, text]); + commitFiles(repo, Object.fromEntries(files)); +} + +export type CliRun = { readonly code: number; readonly stdout: string; readonly stderr: string }; + +export function runCli(args: readonly string[]): CliRun { + try { + const stdout = execFileSync(process.execPath, ["--import", "tsx", CLI, ...args], { + cwd: PACKAGE_DIR, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (error) { + const failed = error as { status: number; stdout: string; stderr: string }; + return { code: failed.status, stdout: failed.stdout, stderr: failed.stderr }; + } +} + +// The gate as a CI job runs it on the checked-out branch: the fixture's map, `main` as the base +// and the fixture's policy unless `policyFile` names another. +export const runGate = ( + repo: RatchetRepo, + extra: readonly string[] = [], + policyFile = POLICY_FILE, +): CliRun => + runCli([ + repo.root, + "--api", + MAP_FILE, + "--api-base", + "main", + "--api-policy", + policyFile, + ...extra, + ]); diff --git a/packages/code-graph/test/api/diff/after/src/core.ts b/packages/code-graph/test/api/diff/after/src/core.ts new file mode 100644 index 00000000..d16157bb --- /dev/null +++ b/packages/code-graph/test/api/diff/after/src/core.ts @@ -0,0 +1,16 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number; readonly delayMs?: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string, radix?: number): number { + return radix === undefined ? Number(text) : Number.parseInt(text, radix); +} + +export function format(n: number): string { + return String(n); +} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/diff/after/src/index.ts b/packages/code-graph/test/api/diff/after/src/index.ts new file mode 100644 index 00000000..8d119dee --- /dev/null +++ b/packages/code-graph/test/api/diff/after/src/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/packages/code-graph/test/api/diff/after/src/labs/index.ts b/packages/code-graph/test/api/diff/after/src/labs/index.ts new file mode 100644 index 00000000..f467784c --- /dev/null +++ b/packages/code-graph/test/api/diff/after/src/labs/index.ts @@ -0,0 +1 @@ +export const flag = true; diff --git a/packages/code-graph/test/api/diff/after/src/testing/expect.ts b/packages/code-graph/test/api/diff/after/src/testing/expect.ts new file mode 100644 index 00000000..5acaac05 --- /dev/null +++ b/packages/code-graph/test/api/diff/after/src/testing/expect.ts @@ -0,0 +1,3 @@ +export function expectEqual(actual: T, expected: NoInfer): boolean { + return actual === expected; +} diff --git a/packages/code-graph/test/api/diff/after/src/testing/index.ts b/packages/code-graph/test/api/diff/after/src/testing/index.ts new file mode 100644 index 00000000..2cd37513 --- /dev/null +++ b/packages/code-graph/test/api/diff/after/src/testing/index.ts @@ -0,0 +1 @@ +export { expectEqual as verify } from "./expect"; diff --git a/packages/code-graph/test/api/diff/after/tsconfig.json b/packages/code-graph/test/api/diff/after/tsconfig.json new file mode 100644 index 00000000..226aa97d --- /dev/null +++ b/packages/code-graph/test/api/diff/after/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/code-graph/test/api/diff/api-map.json b/packages/code-graph/test/api/diff/api-map.json new file mode 100644 index 00000000..63739b92 --- /dev/null +++ b/packages/code-graph/test/api/diff/api-map.json @@ -0,0 +1,5 @@ +{ + ".": { "entry": "src/index.ts", "tier": "stable" }, + "./testing": { "entry": "src/testing/index.ts", "tier": "stable" }, + "./labs": { "entry": "src/labs/index.ts", "tier": "experimental" } +} diff --git a/packages/code-graph/test/api/diff/before/src/core.ts b/packages/code-graph/test/api/diff/before/src/core.ts new file mode 100644 index 00000000..a31db02d --- /dev/null +++ b/packages/code-graph/test/api/diff/before/src/core.ts @@ -0,0 +1,14 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string): number { + return Number(text); +} + +export function legacy(): void {} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/diff/before/src/index.ts b/packages/code-graph/test/api/diff/before/src/index.ts new file mode 100644 index 00000000..8d119dee --- /dev/null +++ b/packages/code-graph/test/api/diff/before/src/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/packages/code-graph/test/api/diff/before/src/testing/expect.ts b/packages/code-graph/test/api/diff/before/src/testing/expect.ts new file mode 100644 index 00000000..5acaac05 --- /dev/null +++ b/packages/code-graph/test/api/diff/before/src/testing/expect.ts @@ -0,0 +1,3 @@ +export function expectEqual(actual: T, expected: NoInfer): boolean { + return actual === expected; +} diff --git a/packages/code-graph/test/api/diff/before/src/testing/index.ts b/packages/code-graph/test/api/diff/before/src/testing/index.ts new file mode 100644 index 00000000..d22df6f1 --- /dev/null +++ b/packages/code-graph/test/api/diff/before/src/testing/index.ts @@ -0,0 +1 @@ +export { expectEqual as check } from "./expect"; diff --git a/packages/code-graph/test/api/diff/before/tsconfig.json b/packages/code-graph/test/api/diff/before/tsconfig.json new file mode 100644 index 00000000..226aa97d --- /dev/null +++ b/packages/code-graph/test/api/diff/before/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/code-graph/test/api/fixture/api-map.json b/packages/code-graph/test/api/fixture/api-map.json new file mode 100644 index 00000000..34406780 --- /dev/null +++ b/packages/code-graph/test/api/fixture/api-map.json @@ -0,0 +1,6 @@ +{ + ".": { "entry": "src/index.ts", "tier": "stable" }, + "./testing": { "entry": "src/testing/index.ts", "tier": "stable" }, + "./renamed": { "entry": "src/renamed/index.ts" }, + "./relay": { "entry": "src/relay/index.ts", "tier": "experimental" } +} diff --git a/packages/code-graph/test/api/fixture/src/fns.ts b/packages/code-graph/test/api/fixture/src/fns.ts new file mode 100644 index 00000000..de249ecf --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/fns.ts @@ -0,0 +1,13 @@ +// A private type: no entry publishes it, and `plain` reads it. +type Step = { readonly by: number }; + +/** Adds one, or `step.by`. */ +export function plain(n: number, step?: Step): number { + return n + (step?.by ?? 1); +} + +export function over(a: string): string; +export function over(a: number): number; +export function over(a: string | number): string | number { + return a; +} diff --git a/packages/code-graph/test/api/fixture/src/index.ts b/packages/code-graph/test/api/fixture/src/index.ts new file mode 100644 index 00000000..001e5864 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/index.ts @@ -0,0 +1,2 @@ +export * from "./fns"; +export * from "./types"; diff --git a/packages/code-graph/test/api/fixture/src/relay/index.ts b/packages/code-graph/test/api/fixture/src/relay/index.ts new file mode 100644 index 00000000..77703e44 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/relay/index.ts @@ -0,0 +1,3 @@ +import { LIMIT } from "../values"; + +export { LIMIT }; diff --git a/packages/code-graph/test/api/fixture/src/renamed/index.ts b/packages/code-graph/test/api/fixture/src/renamed/index.ts new file mode 100644 index 00000000..bfacd193 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/renamed/index.ts @@ -0,0 +1 @@ +export { plain as increment } from "../fns"; diff --git a/packages/code-graph/test/api/fixture/src/testing/index.ts b/packages/code-graph/test/api/fixture/src/testing/index.ts new file mode 100644 index 00000000..24cf38d6 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/testing/index.ts @@ -0,0 +1 @@ +export { Box, make } from "../values"; diff --git a/packages/code-graph/test/api/fixture/src/types.ts b/packages/code-graph/test/api/fixture/src/types.ts new file mode 100644 index 00000000..7ecb94a3 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/types.ts @@ -0,0 +1,12 @@ +export type Alias = { readonly id: string }; + +interface Corner { + readonly x: number; + readonly y: number; +} + +/** A shape. */ +export interface Shape { + readonly side: number; + readonly corner: Corner; // private, so its text rides on Shape +} diff --git a/packages/code-graph/test/api/fixture/src/values.ts b/packages/code-graph/test/api/fixture/src/values.ts new file mode 100644 index 00000000..dbe33320 --- /dev/null +++ b/packages/code-graph/test/api/fixture/src/values.ts @@ -0,0 +1,9 @@ +export class Box { + readonly v = 1; +} + +export const make = () => ({ a: 1 }); + +export const LIMIT = 3; + +export const hidden = "declared, published by no entry"; diff --git a/packages/code-graph/test/api/fixture/tsconfig.json b/packages/code-graph/test/api/fixture/tsconfig.json new file mode 100644 index 00000000..226aa97d --- /dev/null +++ b/packages/code-graph/test/api/fixture/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/code-graph/test/api/ratchet/api-map.json b/packages/code-graph/test/api/ratchet/api-map.json new file mode 100644 index 00000000..fa451b80 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/api-map.json @@ -0,0 +1,5 @@ +{ + ".": { "entry": "src/index.ts", "tier": "stable" }, + "./battery": { "entry": "src/battery/index.ts", "tier": "battery" }, + "./labs": { "entry": "src/labs/index.ts", "tier": "experimental" } +} diff --git a/packages/code-graph/test/api/ratchet/base/package.json b/packages/code-graph/test/api/ratchet/base/package.json new file mode 100644 index 00000000..96c576a3 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/base/package.json @@ -0,0 +1,5 @@ +{ + "name": "@demo/pkg", + "version": "1.0.0", + "type": "module" +} diff --git a/packages/code-graph/test/api/ratchet/base/src/battery/index.ts b/packages/code-graph/test/api/ratchet/base/src/battery/index.ts new file mode 100644 index 00000000..eb038a55 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/base/src/battery/index.ts @@ -0,0 +1,5 @@ +export function charge(level: number): number { + return level + 1; +} + +export function drain(): void {} diff --git a/packages/code-graph/test/api/ratchet/base/src/index.ts b/packages/code-graph/test/api/ratchet/base/src/index.ts new file mode 100644 index 00000000..a31db02d --- /dev/null +++ b/packages/code-graph/test/api/ratchet/base/src/index.ts @@ -0,0 +1,14 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string): number { + return Number(text); +} + +export function legacy(): void {} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/base/src/labs/index.ts b/packages/code-graph/test/api/ratchet/base/src/labs/index.ts new file mode 100644 index 00000000..97abab1e --- /dev/null +++ b/packages/code-graph/test/api/ratchet/base/src/labs/index.ts @@ -0,0 +1,3 @@ +export const flag = true; + +export function trial(): void {} diff --git a/packages/code-graph/test/api/ratchet/base/tsconfig.json b/packages/code-graph/test/api/ratchet/base/tsconfig.json new file mode 100644 index 00000000..226aa97d --- /dev/null +++ b/packages/code-graph/test/api/ratchet/base/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/code-graph/test/api/ratchet/branches/battery-added/src/battery/index.ts b/packages/code-graph/test/api/ratchet/branches/battery-added/src/battery/index.ts new file mode 100644 index 00000000..7286f097 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/battery-added/src/battery/index.ts @@ -0,0 +1,7 @@ +export function charge(level: number): number { + return level + 1; +} + +export function drain(): void {} + +export const CAPACITY = 100; diff --git a/packages/code-graph/test/api/ratchet/branches/battery-changed/src/battery/index.ts b/packages/code-graph/test/api/ratchet/branches/battery-changed/src/battery/index.ts new file mode 100644 index 00000000..11bff814 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/battery-changed/src/battery/index.ts @@ -0,0 +1,5 @@ +export function charge(level: number, by = 1): number { + return level + by; +} + +export function drain(): void {} diff --git a/packages/code-graph/test/api/ratchet/branches/battery-removed/src/battery/index.ts b/packages/code-graph/test/api/ratchet/branches/battery-removed/src/battery/index.ts new file mode 100644 index 00000000..a855f53d --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/battery-removed/src/battery/index.ts @@ -0,0 +1,3 @@ +export function charge(level: number): number { + return level + 1; +} diff --git a/packages/code-graph/test/api/ratchet/branches/every-row/src/battery/index.ts b/packages/code-graph/test/api/ratchet/branches/every-row/src/battery/index.ts new file mode 100644 index 00000000..175744bc --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/every-row/src/battery/index.ts @@ -0,0 +1,5 @@ +export function charge(level: number, by = 1): number { + return level + by; +} + +export const CAPACITY = 100; diff --git a/packages/code-graph/test/api/ratchet/branches/every-row/src/index.ts b/packages/code-graph/test/api/ratchet/branches/every-row/src/index.ts new file mode 100644 index 00000000..fab8086a --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/every-row/src/index.ts @@ -0,0 +1,16 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number; readonly delayMs?: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string): number { + return Number(text); +} + +export function format(n: number): string { + return String(n); +} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/branches/every-row/src/labs/index.ts b/packages/code-graph/test/api/ratchet/branches/every-row/src/labs/index.ts new file mode 100644 index 00000000..e537674a --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/every-row/src/labs/index.ts @@ -0,0 +1,3 @@ +export const flag = false; + +export const level = 2; diff --git a/packages/code-graph/test/api/ratchet/branches/labs-added/src/labs/index.ts b/packages/code-graph/test/api/ratchet/branches/labs-added/src/labs/index.ts new file mode 100644 index 00000000..7b3ed5de --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/labs-added/src/labs/index.ts @@ -0,0 +1,5 @@ +export const flag = true; + +export function trial(): void {} + +export const level = 2; diff --git a/packages/code-graph/test/api/ratchet/branches/labs-changed/src/labs/index.ts b/packages/code-graph/test/api/ratchet/branches/labs-changed/src/labs/index.ts new file mode 100644 index 00000000..c6c89f0f --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/labs-changed/src/labs/index.ts @@ -0,0 +1,3 @@ +export const flag = false; + +export function trial(): void {} diff --git a/packages/code-graph/test/api/ratchet/branches/labs-removed/src/labs/index.ts b/packages/code-graph/test/api/ratchet/branches/labs-removed/src/labs/index.ts new file mode 100644 index 00000000..f467784c --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/labs-removed/src/labs/index.ts @@ -0,0 +1 @@ +export const flag = true; diff --git a/packages/code-graph/test/api/ratchet/branches/no-api-change/src/index.ts b/packages/code-graph/test/api/ratchet/branches/no-api-change/src/index.ts new file mode 100644 index 00000000..25cb2d95 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/no-api-change/src/index.ts @@ -0,0 +1,16 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +/** Builds a runner. */ +export function make(options: Options): { options: Options } { + const kept = options; + return { options: kept }; +} + +export function parse(text: string): number { + return Number.parseFloat(text); +} + +export function legacy(): void {} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/branches/stable-added/src/index.ts b/packages/code-graph/test/api/ratchet/branches/stable-added/src/index.ts new file mode 100644 index 00000000..48b26340 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/stable-added/src/index.ts @@ -0,0 +1,18 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string): number { + return Number(text); +} + +export function format(n: number): string { + return String(n); +} + +export function legacy(): void {} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/branches/stable-changed/src/index.ts b/packages/code-graph/test/api/ratchet/branches/stable-changed/src/index.ts new file mode 100644 index 00000000..2dd5b5bb --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/stable-changed/src/index.ts @@ -0,0 +1,14 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string, radix?: number): number { + return radix === undefined ? Number(text) : Number.parseInt(text, radix); +} + +export function legacy(): void {} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/branches/stable-removed/src/index.ts b/packages/code-graph/test/api/ratchet/branches/stable-removed/src/index.ts new file mode 100644 index 00000000..4da90635 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/branches/stable-removed/src/index.ts @@ -0,0 +1,12 @@ +// A private type: no entry publishes it, and `make` reads it. +type Options = { readonly retries: number }; + +export function make(options: Options): { options: Options } { + return { options }; +} + +export function parse(text: string): number { + return Number(text); +} + +export const VERSION = "1"; diff --git a/packages/code-graph/test/api/ratchet/policy.json b/packages/code-graph/test/api/ratchet/policy.json new file mode 100644 index 00000000..fa5a92d3 --- /dev/null +++ b/packages/code-graph/test/api/ratchet/policy.json @@ -0,0 +1,20 @@ +{ + "callout": "**Breaking", + "tiers": { + "stable": { + "added": { "bump": "minor" }, + "changed": { "bump": "minor", "callout": true }, + "removed": { "bump": "major", "callout": true } + }, + "battery": { + "added": { "bump": "patch" }, + "changed": { "bump": "minor" }, + "removed": { "bump": "minor", "callout": true } + }, + "experimental": { + "added": { "bump": "none" }, + "changed": { "bump": "patch" }, + "removed": { "bump": "patch", "callout": true } + } + } +} diff --git a/packages/code-graph/tsup.config.ts b/packages/code-graph/tsup.config.ts index 81cb0fc3..baf8d6d8 100644 --- a/packages/code-graph/tsup.config.ts +++ b/packages/code-graph/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ project: "src/project.ts", scc: "src/scc.ts", boundaries: "src/boundaries/ledger.ts", + api: "src/api.ts", }, format: ["esm"], dts: true, diff --git a/packages/tea/MAINTAINING.md b/packages/tea/MAINTAINING.md index 01b0d7cb..c857ef7c 100644 --- a/packages/tea/MAINTAINING.md +++ b/packages/tea/MAINTAINING.md @@ -145,6 +145,11 @@ The package is at 0.x. Semver's 0.x escape hatch is not the policy — the tier obligation beyond noting the change. Do not build a stability-sensitive consumer on an experimental subpath. +An added published name on an existing subpath owes at least a **minor** changeset on a +`stable` or `battery` subpath and any bump on an `experimental` one, with no separate +changelog callout on any tier — the changeset entry naming the export is the record +([ruling](https://github.com/kamp-us/demlik/issues/612#issuecomment-6102843973)). + ### Removal, while 0.x A subpath, module or exported name is removed in the **same PR** that replaces it — no diff --git a/packages/tea/package.json b/packages/tea/package.json index 548937fc..4f444121 100644 --- a/packages/tea/package.json +++ b/packages/tea/package.json @@ -146,6 +146,7 @@ "typecheck:consumers": "tsc -p tsconfig.consumers.json", "docs:reference": "TEA_DOCS_WRITE=1 vitest run src/docs/reference/generate-reference.test.ts", "docs:reference:strays": "vitest run src/docs/reference/generate-reference.test.ts -t 'holds nothing the generator does not write'", + "api:ratchet": "vitest run src/api-ratchet/api-ratchet.test.ts", "docs:reference:check": "pnpm run docs:reference:strays && pnpm run docs:reference && git diff --exit-code -- docs/reference", "prepublishOnly": "NODE_OPTIONS=--max-old-space-size=8192 tsup && node scripts/verify-exports.mjs", "verify:exports": "node scripts/verify-exports.mjs", @@ -189,6 +190,7 @@ "devDependencies": { "@anthropic-ai/sdk": "catalog:", "@cloudflare/workers-types": "catalog:", + "@demlik/code-graph": "workspace:*", "@langfuse/otel": "catalog:", "@opentelemetry/api": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", diff --git a/packages/tea/src/api-ratchet/api-map.ts b/packages/tea/src/api-ratchet/api-map.ts new file mode 100644 index 00000000..499f7a1f --- /dev/null +++ b/packages/tea/src/api-ratchet/api-map.ts @@ -0,0 +1,116 @@ +/** + * tea's published-API map: every export subpath with the source file it is + * built from and the tier it is stamped with. It is the input + * `@demlik/code-graph/api` asks its caller for, and every cell is READ from the + * file that already states it: + * + * - the subpaths: `package.json` `exports`, bar `./package.json` and `*.css` + * - the source entry: `tsup.config.ts` `entry`, whose key is the file tsup + * writes under its out folder, so an export's `import` target names its key + * - the tier: `MAINTAINING.md`'s tier table, through the one reader the + * reference generator uses + * + * A subpath one of them does not cover is an error here, never a guess. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import tsupConfig from "../../tsup.config"; +import { parseTierTable, type Tier } from "../docs/reference/tier-table"; + +export const PKG_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +export type ExportTarget = string | { readonly import?: string }; + +export type ApiMapSources = { + /** `package.json` `exports`. */ + readonly exports: Readonly>; + /** tsup's `entry`: the file it writes, without extension → its source. */ + readonly entries: Readonly>; + /** tsup's out folder, relative to the package root. */ + readonly outDir: string; + /** Subpath → tier, as the tier table stamps it. */ + readonly tiers: ReadonlyMap; +}; + +export type TeaApiMap = Readonly< + Record +>; + +/** Metadata passthrough and assets carry no API, so they are not subpaths. */ +const isApiSubpath = (subpath: string): boolean => + subpath !== "./package.json" && !subpath.endsWith(".css"); + +/** The tsup entry key an export target was built from: `./dist/a/index.js` → `a/index`. */ +function entryKey(target: ExportTarget, outDir: string): string | undefined { + const built = typeof target === "string" ? target : target.import; + const prefix = `./${outDir}/`; + if (built === undefined || !built.startsWith(prefix)) return undefined; + return built.slice(prefix.length).replace(/\.js$/, ""); +} + +/** + * Join the three sources into the map. Throws naming every subpath with no tier + * row and every subpath whose export target no tsup entry builds. + */ +export function buildApiMap(sources: ApiMapSources): TeaApiMap { + const map: Record = {}; + const unstamped: string[] = []; + const unbuilt: string[] = []; + for (const [subpath, target] of Object.entries(sources.exports)) { + if (!isApiSubpath(subpath)) continue; + const tier = sources.tiers.get(subpath); + const key = entryKey(target, sources.outDir); + const entry = + key !== undefined && Object.hasOwn(sources.entries, key) + ? sources.entries[key] + : undefined; + if (tier === undefined) unstamped.push(subpath); + if (entry === undefined) unbuilt.push(subpath); + if (tier !== undefined && entry !== undefined) { + map[subpath] = { entry, tier }; + } + } + const problems = [ + ...unstamped.map((s) => ` ${s} has no row in MAINTAINING.md's tier table`), + ...unbuilt.map( + (s) => ` ${s} has no entry in tsup.config.ts that builds its target`, + ), + ]; + if (problems.length > 0) { + throw new Error( + `api-ratchet: the export map names subpaths the API map cannot place:\n${problems.join("\n")}`, + ); + } + return map; +} + +/** tsup's `entry` and out folder, as `tsup.config.ts` states them. */ +function tsupEntries(): Pick { + if (typeof tsupConfig === "function" || Array.isArray(tsupConfig)) { + throw new Error( + "api-ratchet: tsup.config.ts must export one options object", + ); + } + const { entry, outDir = "dist" } = tsupConfig; + if (entry === undefined || Array.isArray(entry)) { + throw new Error( + "api-ratchet: tsup.config.ts `entry` must map each built file to its source", + ); + } + return { entries: entry, outDir }; +} + +/** The API map of the package as it stands on disk. */ +export function readApiMap(): TeaApiMap { + const read = (file: string): string => + readFileSync(new URL(`../../${file}`, import.meta.url), "utf8"); + const pkg = JSON.parse(read("package.json")) as { + exports: Readonly>; + }; + return buildApiMap({ + exports: pkg.exports, + ...tsupEntries(), + tiers: parseTierTable(read("MAINTAINING.md")), + }); +} diff --git a/packages/tea/src/api-ratchet/api-ratchet.test.ts b/packages/tea/src/api-ratchet/api-ratchet.test.ts new file mode 100644 index 00000000..9bc8bdc4 --- /dev/null +++ b/packages/tea/src/api-ratchet/api-ratchet.test.ts @@ -0,0 +1,293 @@ +/** + * The published-API ratchet — one file, two modes keyed on `TEA_API_BASE`: + * + * - TEA_API_BASE= → the check (`pnpm run api:ratchet`, CI on a pull + * request): diff the working tree's published API against that commit and + * fail naming every change that misses the changeset its tier asks for. + * - unset → the mechanism proofs: the API map covers the export map and + * refuses a subpath it cannot place, and the policy gives each tier and + * change kind the verdict `MAINTAINING.md` states. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type ApiDiff, + ApiMapSchema, + type Changeset, + ratchetApiDiff, +} from "@demlik/code-graph/api"; +import { describe, expect, it } from "vitest"; +import { TIERS, type Tier } from "../docs/reference/tier-table"; +import { buildApiMap, PKG_ROOT, readApiMap } from "./api-map"; +import { TEA_BUMP_POLICY } from "./policy"; +import { changeCount, renderVerdict, runApiRatchet } from "./ratchet"; + +const BASE = process.env.TEA_API_BASE; + +const BEFORE = { + text: "export declare function expectCmdEmitted(actual: readonly T[], expected: NoInfer): void;", + references: {}, +}; +const AFTER = { + text: "export declare function expectCmdEmitted(actual: readonly T[], expected: T): void;", + references: {}, +}; + +type Kind = "added" | "changed" | "removed"; + +/** A diff reporting one change to one name, on a subpath of the given tier. */ +const oneChange = (tier: Tier, kind: Kind): ApiDiff => ({ + root: ".", + base: "0123456789abcdef0123456789abcdef01234567", + compiler: "test", + subpaths: { + "./testing": { + tier, + added: kind === "added" ? { expectCmdEmitted: { after: AFTER } } : {}, + changed: + kind === "changed" + ? { expectCmdEmitted: { before: BEFORE, after: AFTER } } + : {}, + removed: + kind === "removed" ? { expectCmdEmitted: { before: BEFORE } } : {}, + }, + }, +}); + +const changeset = (bump: Changeset["bump"], body: string): Changeset => ({ + file: ".changeset/a.md", + bump, + body, +}); + +const judge = (tier: Tier, kind: Kind, changesets: readonly Changeset[]) => + ratchetApiDiff(oneChange(tier, kind), TEA_BUMP_POLICY, { + package: "@demlik/tea", + changesets, + }); + +const CALLOUT = "**Breaking:** `expected` is no longer pinned."; +const PLAIN = "Add `expectCmdEmitted`."; + +describe.runIf(BASE === undefined)("api map", () => { + const tiers = new Map([ + [".", "stable"], + ["./labs", "experimental"], + ]); + const entries = { index: "src/index.ts", "labs/index": "src/labs/index.ts" }; + + it("joins each subpath to its tsup source and its tier", () => { + const map = buildApiMap({ + exports: { + ".": { import: "./dist/index.js" }, + "./labs": "./dist/labs/index.js", + "./labs/styles.css": "./dist/labs/styles.css", + "./package.json": "./package.json", + }, + entries, + outDir: "dist", + tiers, + }); + expect(map).toEqual({ + ".": { entry: "src/index.ts", tier: "stable" }, + "./labs": { entry: "src/labs/index.ts", tier: "experimental" }, + }); + }); + + it("fails naming a subpath with no tier row", () => { + expect(() => + buildApiMap({ + exports: { + ".": { import: "./dist/index.js" }, + "./new": { import: "./dist/new/index.js" }, + }, + entries: { ...entries, "new/index": "src/new/index.ts" }, + outDir: "dist", + tiers, + }), + ).toThrow("./new has no row in MAINTAINING.md's tier table"); + }); + + it("fails naming a subpath no tsup entry builds", () => { + expect(() => + buildApiMap({ + exports: { "./labs": { import: "./dist/labs.js" } }, + entries, + outDir: "dist", + tiers, + }), + ).toThrow("./labs has no entry in tsup.config.ts that builds its target"); + }); + + it("covers every subpath tea exports, each with a source file that exists", () => { + const pkg = JSON.parse( + readFileSync(join(PKG_ROOT, "package.json"), "utf8"), + ) as { exports: Record }; + const exported = Object.keys(pkg.exports).filter( + (s) => s !== "./package.json" && !s.endsWith(".css"), + ); + const map = readApiMap(); + + expect(Object.keys(map).sort()).toEqual(exported.sort()); + expect(ApiMapSchema.safeParse(map).success).toBe(true); + for (const { entry } of Object.values(map)) { + expect(existsSync(join(PKG_ROOT, entry)), entry).toBe(true); + } + expect(map["./testing"]).toEqual({ + entry: "src/testing/index.ts", + tier: "stable", + }); + }); +}); + +describe.runIf(BASE === undefined)("bump policy", () => { + it.each([ + "stable", + "battery", + ] as const)("a %s change or removal needs a minor with the breaking callout", (tier) => { + for (const kind of ["changed", "removed"] as const) { + expect(judge(tier, kind, []).passed).toBe(false); + expect(judge(tier, kind, [changeset("minor", PLAIN)]).passed).toBe(false); + expect(judge(tier, kind, [changeset("patch", CALLOUT)]).passed).toBe( + false, + ); + expect(judge(tier, kind, [changeset("minor", CALLOUT)]).passed).toBe( + true, + ); + } + }); + + it.each([ + "stable", + "battery", + ] as const)("a name added to a %s subpath needs a minor and no callout", (tier) => { + expect(judge(tier, "added", []).passed).toBe(false); + expect(judge(tier, "added", [changeset("patch", PLAIN)]).passed).toBe( + false, + ); + expect(judge(tier, "added", [changeset("minor", PLAIN)]).passed).toBe(true); + }); + + it("an experimental change of any kind needs a changeset of any bump", () => { + for (const kind of ["added", "changed", "removed"] as const) { + expect(judge("experimental", kind, []).passed).toBe(false); + expect( + judge("experimental", kind, [changeset("patch", PLAIN)]).passed, + ).toBe(true); + } + }); + + it("gives every tier of the tier table a row", () => { + expect(Object.keys(TEA_BUMP_POLICY.tiers).sort()).toEqual( + [...TIERS].sort(), + ); + }); +}); + +describe.runIf(BASE === undefined)("verdict text", () => { + it("names the name, subpath, tier and the before and after text of a miss", () => { + const diff = oneChange("stable", "changed"); + const text = renderVerdict( + judge("stable", "changed", []), + changeCount(diff), + TEA_BUMP_POLICY.callout, + ); + + expect(text).toBe( + [ + 'api-ratchet: expectCmdEmitted in ./testing (stable) changed — needs a minor changeset with a "**Breaking" callout; found no changeset', + " before:", + ` ${BEFORE.text}`, + " after:", + ` ${AFTER.text}`, + "api-ratchet: 1 of 1 published name changed against 0123456 without the changeset MAINTAINING.md's semver policy asks for — add one with `pnpm changeset`", + "", + ].join("\n"), + ); + }); + + it("prints a type the name uses only when its text moved", () => { + const make = "export declare function make(options: Options): Store;"; + const diff: ApiDiff = { + ...oneChange("stable", "changed"), + subpaths: { + "./mem": { + tier: "stable", + added: {}, + removed: {}, + changed: { + make: { + before: { + text: make, + references: { + "src/mem.d.ts#Options": "type Options = { fenced?: true };", + "src/mem.d.ts#Store": "type Store = { load(): void };", + }, + }, + after: { + text: make, + references: { + "src/mem.d.ts#Options": + "type Options = { fenced?: boolean };", + "src/mem.d.ts#Store": "type Store = { load(): void };", + }, + }, + }, + }, + }, + }, + }; + const verdict = ratchetApiDiff(diff, TEA_BUMP_POLICY, { + package: "@demlik/tea", + changesets: [], + }); + + const text = renderVerdict(verdict, 1, TEA_BUMP_POLICY.callout); + + expect(text.split("\n").slice(1, 7)).toEqual([ + " before:", + ` ${make}`, + " src/mem.d.ts#Options: type Options = { fenced?: true };", + " after:", + ` ${make}`, + " src/mem.d.ts#Options: type Options = { fenced?: boolean };", + ]); + expect(text).not.toContain("#Store"); + }); + + it("says what a miss found when a changeset lacks the callout", () => { + const text = renderVerdict( + judge("stable", "changed", [changeset("minor", PLAIN)]), + 1, + TEA_BUMP_POLICY.callout, + ); + + expect(text).toContain("found a minor changeset with no callout"); + }); + + it("is one line on a pass", () => { + const text = renderVerdict( + judge("stable", "changed", [changeset("minor", CALLOUT)]), + 1, + TEA_BUMP_POLICY.callout, + ); + + expect(text).toBe( + "api-ratchet: pass — 1 published name changed against 0123456; @demlik/tea changesets since: 1, highest bump minor\n", + ); + }); +}); + +// The compiler emits the whole package twice, once per side of the diff. +describe.runIf(BASE !== undefined)( + "published API against TEA_API_BASE", + { timeout: 300_000 }, + () => { + it("every changed published name carries the changeset its tier asks for", async () => { + const run = await runApiRatchet(BASE as string); + if (!run.passed) expect.fail(`\n${run.text}`); + console.log(run.text); + }); + }, +); diff --git a/packages/tea/src/api-ratchet/policy.ts b/packages/tea/src/api-ratchet/policy.ts new file mode 100644 index 00000000..29b5422f --- /dev/null +++ b/packages/tea/src/api-ratchet/policy.ts @@ -0,0 +1,51 @@ +/** + * tea's bump policy: what changeset a change to a published name owes, by the + * tier of its subpath. This is `MAINTAINING.md`'s "Semver policy" section, with + * ADR 0016 for removals, in the shape `@demlik/code-graph/api`'s ratchet reads. + * It is written here once; when the policy text changes, this changes with it. + * + * - `stable` and `battery`: a changed or removed name lands in a `minor` with + * a breaking-change callout; an added name owes a `minor` and no callout. + * - `experimental`: any change owes a changeset of any bump, and no callout. + * + * The callout marker is how tea's changesets flag a break: a leading + * `**Breaking` (`CHANGELOG.md`). + */ + +import type { Tier } from "../docs/reference/tier-table"; + +type BumpRule = { + readonly bump: "patch" | "minor" | "major"; + readonly callout?: true; +}; + +type TierRow = { + readonly added: BumpRule; + readonly changed: BumpRule; + readonly removed: BumpRule; +}; + +export type TeaBumpPolicy = { + readonly callout: string; + /** One row per tier: a tier with no row does not typecheck. */ + readonly tiers: Readonly>; +}; + +const BREAK_IN_A_MINOR: TierRow = { + added: { bump: "minor" }, + changed: { bump: "minor", callout: true }, + removed: { bump: "minor", callout: true }, +}; + +export const TEA_BUMP_POLICY: TeaBumpPolicy = { + callout: "**Breaking", + tiers: { + stable: BREAK_IN_A_MINOR, + battery: BREAK_IN_A_MINOR, + experimental: { + added: { bump: "patch" }, + changed: { bump: "patch" }, + removed: { bump: "patch" }, + }, + }, +}; diff --git a/packages/tea/src/api-ratchet/ratchet.ts b/packages/tea/src/api-ratchet/ratchet.ts new file mode 100644 index 00000000..d3236fac --- /dev/null +++ b/packages/tea/src/api-ratchet/ratchet.ts @@ -0,0 +1,133 @@ +/** + * The published-API ratchet for tea: diff what every export subpath publishes + * against a base commit, and hold each change to the changeset its tier's + * policy asks for. The diff, the changeset read and the verdict are + * `@demlik/code-graph/api`'s; tea supplies its map and its policy, and prints + * the verdict. + */ + +import { + type ApiDiff, + type ApiEntryText, + type ApiRatchetVerdict, + diffPublishedApi, + ratchetApiDiff, + readChangesetsSince, +} from "@demlik/code-graph/api"; +import { PKG_ROOT, readApiMap } from "./api-map"; +import { TEA_BUMP_POLICY } from "./policy"; + +type Miss = ApiRatchetVerdict["misses"][number]; + +export type RatchetRun = { readonly passed: boolean; readonly text: string }; + +const indent = (text: string, by: string): string => + text + .split("\n") + .map((line) => `${by}${line}`) + .join("\n"); + +/** + * The types a changed name uses whose text moved between the two sides. A name + * folds in every type it reaches, and one edit rarely touches more than one of + * them, so the rest would only bury the line that changed. + */ +function movedReferences(miss: Miss): ReadonlySet { + if (miss.before === null || miss.after === null) return new Set(); + const before = miss.before.references; + const after = miss.after.references; + return new Set( + [...Object.keys(before), ...Object.keys(after)].filter( + (key) => before[key] !== after[key], + ), + ); +} + +/** One side of a change: its declaration text, then each moved type it uses. */ +function sideLines( + label: string, + side: ApiEntryText | null, + moved: ReadonlySet, +): string[] { + if (side === null) return []; + const references = Object.keys(side.references) + .filter((key) => moved.has(key)) + .sort() + .map((key) => indent(`${key}: ${side.references[key]}`, " ")); + return [` ${label}:`, indent(side.text, " "), ...references]; +} + +function owed(needs: Miss["needs"], callout: string): string { + const bump = `a ${needs.bump} changeset`; + return needs.callout + ? `${bump} with a ${JSON.stringify(callout)} callout` + : bump; +} + +function found(verdict: ApiRatchetVerdict, needs: Miss["needs"]): string { + if (verdict.highestBump === "none") return "no changeset"; + if (!needs.callout) return `a ${verdict.highestBump} changeset`; + const callout = verdict.calloutFound ? "the" : "no"; + return `a ${verdict.highestBump} changeset with ${callout} callout`; +} + +function missLines( + miss: Miss, + verdict: ApiRatchetVerdict, + callout: string, +): string[] { + const moved = movedReferences(miss); + return [ + `api-ratchet: ${miss.name} in ${miss.subpath} (${miss.tier}) ${miss.kind} — needs ${owed(miss.needs, callout)}; found ${found(verdict, miss.needs)}`, + ...sideLines("before", miss.before, moved), + ...sideLines("after", miss.after, moved), + ]; +} + +/** How many published names the diff reports as added, changed or removed. */ +export const changeCount = (diff: ApiDiff): number => + Object.values(diff.subpaths).reduce( + (count, { added, changed, removed }) => + count + + Object.keys(added).length + + Object.keys(changed).length + + Object.keys(removed).length, + 0, + ); + +/** + * The verdict as text: one line on a pass; on a miss, one block per change that + * misses its bump — name, subpath, tier, what it needs, and its before and after + * text with the types it uses that moved — then a summary line. Nothing in it + * varies between two runs of one tree. + */ +export function renderVerdict( + verdict: ApiRatchetVerdict, + changes: number, + callout: string, +): string { + const base = verdict.base.slice(0, 7); + const counted = `${changes} published ${changes === 1 ? "name" : "names"} changed`; + if (verdict.passed) { + return `api-ratchet: pass — ${counted} against ${base}; ${verdict.package} changesets since: ${verdict.changesets.length}, highest bump ${verdict.highestBump}\n`; + } + const blocks = verdict.misses.flatMap((miss) => + missLines(miss, verdict, callout), + ); + const summary = `api-ratchet: ${verdict.misses.length} of ${counted} against ${base} without the changeset MAINTAINING.md's semver policy asks for — add one with \`pnpm changeset\``; + return `${[...blocks, summary].join("\n")}\n`; +} + +/** Run the ratchet on the working tree against the commit `base` names. */ +export async function runApiRatchet(base: string): Promise { + const diff = await diffPublishedApi(PKG_ROOT, readApiMap(), base); + const verdict = ratchetApiDiff( + diff, + TEA_BUMP_POLICY, + readChangesetsSince(PKG_ROOT, diff.base), + ); + return { + passed: verdict.passed, + text: renderVerdict(verdict, changeCount(diff), TEA_BUMP_POLICY.callout), + }; +} diff --git a/packages/tea/tsconfig.json b/packages/tea/tsconfig.json index 02a00af7..bb029c3a 100644 --- a/packages/tea/tsconfig.json +++ b/packages/tea/tsconfig.json @@ -22,6 +22,11 @@ "exclude": [ "dist", "node_modules", + // The API ratchet is a CI check that calls code-graph's library and reads + // `tsup.config.ts`. Nothing tea publishes imports it, so it stays out of the + // shipped program, and out of the declarations the ratchet itself emits + // from this config; `tsconfig.test.json` typechecks it. + "src/api-ratchet", "**/*.test.ts", "**/*.test.tsx" ] diff --git a/packages/tea/tsconfig.test.json b/packages/tea/tsconfig.test.json index 2c375b10..ae9294a3 100644 --- a/packages/tea/tsconfig.test.json +++ b/packages/tea/tsconfig.test.json @@ -35,7 +35,11 @@ // imports the specifier, so this is scoped to the test program deliberately. "paths": { "@demlik/tea": ["./src/index.ts"], - "@demlik/tea/*": ["./src/*/index.ts", "./src/*"] + "@demlik/tea/*": ["./src/*/index.ts", "./src/*"], + // The API ratchet (`src/api-ratchet`, which only this program compiles) + // calls code-graph's library. CI typechecks before it builds, so the + // specifier resolves to source, not to a `dist/` that does not exist yet. + "@demlik/code-graph/api": ["../code-graph/src/api.ts"] } }, "include": ["src"], diff --git a/packages/tea/vitest.config.ts b/packages/tea/vitest.config.ts index 42a32d76..f80558ec 100644 --- a/packages/tea/vitest.config.ts +++ b/packages/tea/vitest.config.ts @@ -73,6 +73,12 @@ export default defineConfig({ alias: [ { find: /^@demlik\/tea$/, replacement: abs("src/index.ts") }, ...subpathAliases, + // The API ratchet (`src/api-ratchet`) calls code-graph's library, and CI + // tests before it builds, so the specifier resolves to source here too. + { + find: /^@demlik\/code-graph\/api$/, + replacement: abs("../code-graph/src/api.ts"), + }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9176705d..3b984107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -265,6 +265,9 @@ importers: '@cloudflare/workers-types': specifier: 'catalog:' version: 4.20260312.1 + '@demlik/code-graph': + specifier: workspace:* + version: link:../code-graph '@langfuse/otel': specifier: 'catalog:' version: 5.11.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))