Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cf2140d
docs(code-graph): SPEC §13 for the published-API mode (#608)
usirin Oct 10, 2026
fcd1f73
Merge branch 'build/608-published-api-spec-5ac04cc6' into epic/604
usirin Oct 10, 2026
b607367
docs(tea): state the bump an added published name owes (#612)
usirin Oct 10, 2026
40dfd0e
Merge branch 'build/612-added-name-bump-c0b62020' into epic/604
usirin Oct 10, 2026
f52a74e
Merge commit '1dbb4e44add5926020b1005e178d2ce317fa97b8' into epic/604
usirin Oct 10, 2026
2cb17bd
feat(code-graph): --api lists a package's published API per export su…
usirin Oct 10, 2026
cd7d4da
Merge branch 'build/609-published-api-view-41dbadf0' into epic/604
usirin Oct 10, 2026
377bce3
Merge commit '79dd52a6028e0953b070d68c6319ca36b7ec9369' into epic/604
usirin Oct 10, 2026
8d53b70
feat(code-graph): --api-base diffs a package's published API against …
usirin Oct 10, 2026
00a5781
Merge branch 'build/610-api-diff-55f613e2' into epic/604
usirin Oct 10, 2026
5c06853
Merge commit 'f6339b07540a64a64f13df0240723067dc3a2c9e' into epic/604
usirin Oct 10, 2026
e7e198c
feat(code-graph): --api-policy gates the published-API diff on change…
usirin Oct 10, 2026
99677a2
Merge branch 'build/611-api-diff-ratchet-63770077' into epic/604
usirin Oct 10, 2026
7bea94c
feat(tea): CI holds a published-API change to the changeset its tier …
usirin Oct 10, 2026
ca67c31
fix(tea): the API ratchet prints only the types a changed name uses t…
usirin Oct 11, 2026
608e337
Merge branch 'build/613-tea-api-ratchet-ci-2104d291' into epic/604
usirin Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/code-graph-published-api-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@demlik/code-graph": minor
---

New: diff a package's published API against a base commit. `code-graph <package> --api <map>
--api-base <rev>` and `diffPublishedApi(root, map, base)` from `@demlik/code-graph/api` list, per
export subpath, the names added, removed and changed since `<rev>`, 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.
15 changes: 15 additions & 0 deletions .changeset/code-graph-published-api-ratchet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@demlik/code-graph": minor
---

New: gate a PR on its published-API diff. `code-graph <package> --api <map> --api-base <rev>
--api-policy <file>` checks every added, removed and changed name against the changesets added
since `<rev>`. 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.
14 changes: 14 additions & 0 deletions .changeset/code-graph-published-api-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@demlik/code-graph": minor
---

New opt-in mode: the published-API view. `code-graph <package> --api <map>` 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 (`{ "<subpath>": { "entry": "<source file>", "tier"?: "<string>" } }`); 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`.
19 changes: 19 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
408 changes: 408 additions & 0 deletions packages/code-graph/SPEC.md

Large diffs are not rendered by default.

148 changes: 147 additions & 1 deletion packages/code-graph/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <map>` prints what a package publishes, per export subpath, as a
consumer's types see it. `<directory>` is the package root. `<map>` 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 `<emitted file>#<declared name>`. A change to
a private type therefore changes the published name that uses it.

`--api-base <rev>` diffs that view against a base commit. `<rev>` 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 <file>` gates that diff on the package's changesets, for a CI
job. `<file>` 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
`<directory>/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 <map>` | `PublishedApi` JSON | 0, 2 |
| `--api <map> --api-base <rev>` | `ApiDiff` JSON | 0, 2 |
| `--api <map> --api-base <rev> --api-policy <file>` | 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
`<directory>/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 |
Expand Down Expand Up @@ -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).
113 changes: 112 additions & 1 deletion packages/code-graph/docs/reference/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<PublishedApi>`: per subpath, every published name with its text and references |
| `diffPublishedApi(root, map, base, options?)` | `Promise<ApiDiff>`: 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, `{ "<subpath>": { 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<S, M extends {\n type: string;\n}, …>(…): 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<C>): 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: { "<tier>": { 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`.
Expand Down
4 changes: 4 additions & 0 deletions packages/code-graph/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading
Loading