fix(api-surface): record the shape behind every exported name - #953
Merged
Conversation
check:version-bump compared export names only, so it reported "consumer surface unchanged" for #946, which removed WorkerView.cwd from a public interface, and for #949, which added a member to the public PromotionVerdict.reason union. Neither moved a name, so api-surface.json was byte-identical across both and the 0.154.0 bump was a human judgement rather than a gate result. The record now states a shape digest beside each kind, taken over the built declaration with comments removed, whitespace collapsed, the declaration's own local name blanked, and every type reference rewritten to a stable token. A reference to a symbol this package exports contributes that symbol's public name, so an edit is reported once, on the line of the symbol that changed. A shape move is classified breaking: telling an added optional field from a removed required one is a subtyping question, and the record states structure. Refs #946, #949
tangletools
approved these changes
Aug 21, 2026
tangletools
left a comment
Contributor
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — e3bad527
This PR was opened by the trusted drewstone account.
This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
check:version-bumpcompares export names. It cannot see a shape, so it answered "consumer surface unchanged" for two merged pull requests that changed one:WorkerView.cwdremoved from a public interface (src/tui/top-model.ts:102)package.json: consumer surface unchanged at 0.153.1'cost-unknown'added to the publicPromotionVerdict.reasonunion, plus a newcostUnknownTasksfield (src/runtime/promotion-gate.ts:48)package.json: consumer surface unchanged at 0.153.2Both moved zero names, so
api-surface.jsonwas byte-identical across both. A gate that reports "safe" when the surface moved is worse than no gate: 0.154.0 was a judgement call that happened to be right, and 0.153.1 shipped a removed public field under a version the registry already held.lib/api-surface.mjsnamed this limit in its own header — "never the shape behind a name … Deeper comparison would need a full type checker on both sides". That premise was wrong. The built.d.tsis the shape; no checker is needed to read it.Change
api-surface.jsonnow records a shape digest beside each kind —"WorkerView": "type 5e430bc3709c"— taken over the built declaration, normalized so only a consumer-visible edit moves it:AgentProfile→AgentProfile$1when two modules declare one identifier) moves nothing;package:name, and one this package declares without exporting contributes its own digest.Referencing by public name is what keeps the report readable: editing
WorkerViewmovesWorkerView's line and not the 40 types that reach it. Measured on this tree, 1801 declarations are reachable from the public surface and only 10 are unexported, so inlining is the rare case, not the common one.check:version-bumpgains one bucket,export shape changed, and calls it breaking. Telling an added optional field from a removed required one is a subtyping question and this record states structure; guessing "additive" on a real break is the outcome the gate exists to stop. On 0.x that asks for a minor.The boundary, and why it is there
The gate must not fire on internal-only work, so I measured it against the eleven pull requests merged into this repo yesterday — nine of which are pure internal refactors (abort cascade, spend fold, JSONL spine, lifecycle vocabulary, three copied primitives). Each pair was built from its own commit and compared with the new record:
./tui WorkerView./kernel PromotionVerdict2 reports across 11 pull requests and 2120 symbols, and both are the real ones. Zero false positives.
Verdicts on the two known misses, with the fixed gate:
Four things deliberately do not move a digest, each pinned by a test: a doc comment, a reformat, a bundler rename, and an edit to a type this package does not export through the changed symbol.
What this still does not see
A dependency's own type moving under a fixed range. A re-exported external symbol records
package:name, never that package's structure, because it moves with the dependency range — which the manifest half of this check already gates. Widening the digest to node_modules would make the record depend on what happens to be installed.Proof
The record survives a clean
rm -rf dist && pnpm run buildbyte-identically, and--writeis idempotent.No version bump:
scripts/andapi-surface.jsonare outsidefiles, so nothing a consumer installs changed.Simplification
Simplification: the shape half of the surface record replaces the human judgement that decided 0.154.0; the two scripts and the shared lib keep one owner for the record format, and
check-version-bump.mjsstill needs nothing but git.Net: +621 / -46 lines of script and test across 5 files, plus the two regenerated records; 0 paths removed, 1 blind spot closed.
Not done here:
agent-knowledgecarries these three files byte-identical and needs the same change plus a regenerated record — a separate pull request in that repository, because its record must be regenerated from its own build.agent-evaldoes not have these scripts at all.Tests: +6 (
api-surface-record.test.ts— a removed field moves its type's digest and nothing else's, a union member added moves it, a doc comment or reformat does not, a bundler rename does not;version-bump-check.test.ts— a shape move with no bump is refused and names the symbol, and a base record that states no shape is not compared), -0 deleted.Refs #946, #949