fix(spec): emit one api-surface row per declared kind, so deleting the value half of a dual declaration is breaking (#15919) - #16554
Conversation
TypeScript merges `export const X` and `export type X` into one symbol carrying both flags. `build-api-surface.ts` mapped that symbol through a first-match-wins `kindOf` that tested TypeAlias before Variable, so the shard recorded `X (type)` alone and the value half was never enumerated. Deleting `export const X` therefore left the shard byte-identical and `check:api-surface` green on a removed public value export. `kindsOf` now returns every kind the flags declare, and `buildSurface` emits one row per kind. The row grammar `Name (kind)` is unchanged; only completeness moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…rations Mechanical `gen:api-surface` output. +134 rows over 10 of 17 entry points, every one of them the previously-unenumerated `(const)` half of a name that also declares a type. Pure insertion: 0 rows removed, 0 modified, no reordering — the code-unit sort places `Name (const)` immediately before its existing `Name (type)`. Export total 5277 -> 5411; api-surface-signatures.json unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
api-surface-dual-kind-rows.pin.test.ts asserts the committed shards carry both halves of a dual-declared name, with negative controls so a generator that stamped every kind onto every name fails here too. check:api-surface cannot catch that regression on its own: it compares the generator against its own regenerated output. docs-import-surface's fixture now models the merged declaration as the two rows the generator really emits, and two new cases pin why two rows and not a combined kind: the extra row leaves both resolvers unmoved, while a combined kind throws and, after widening the parser, silently resolves to no type at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
📓 Docs Drift Check
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract review at
|
Fixes #15919
Clause-②: yes
Read this first — the diff is large and almost entirely mechanical
134 of the changed lines are generated rows under
packages/spec/api-surface/. Before reading them:./api48,./system39,./ui14,./data13,./automation6,./security5,./kernel4,./shared3,.1,./identity1.git diff --numstatoverapi-surface/reads48/0 · 39/0 · 14/0 · 13/0 · 6/0 · 5/0 · 4/0 · 3/0 · 1/0 · 1/0— zero rows removed, zero modified, no reordering, because the code-unit sort placesName (const)immediately before its existingName (type)sibling.(const)half of a name that was already exported and already recorded — recorded as(type)alone. Nothing was added to or removed from the package:api-surface-signatures.jsondid not move, and the builtdist/is identical (dist/.build-input-hashise0550924e60ca64ebefore and after, since nosrc/file changed).Name (kind); only completeness moves.The gate's own diff engine says the same thing in one line — run against the pre-regeneration baseline it printed:
The two files worth actually reading are
packages/spec/scripts/build-api-surface.tsand the newpackages/spec/scripts/api-surface-dual-kind-rows.pin.test.ts.The defect
TypeScript merges an
export const Xand anexport type Xinto ONE symbol whose flags carry both.build-api-surface.tsmapped that symbol through a first-match-winskindOfthat testedTypeAliasbeforeVariable, so a dual-declared name always printed(type)and the value half was never enumerated. The shard format is name-keyed and recorded it once.The population is closed at 132 symbols, every one of them the same
type SHADOWS constshape — measured on the previous lap from the gate's own TypeScript checker, not by grep. 134 rather than 132 rows because two names are exported from two entry points each.The repair
kindsOfnow returns every kind the symbol's flags declare, andbuildSurfaceemits one row per kind. Ordering is preserved from the old lookup so the shards stay stable, andotheris still the answer for a symbol matching no branch.⛔ Not a combined kind (
Name (const, type)). That was settled by exercising the real consumer,packages/spec/scripts/lib/docs-import-surface.ts:loadEntrySurfacesalready models a name as a set of kinds, soresolveValueNameandresolveTypeNamestay bit-identical. Zero consumer changes needed.cannot parse entry "…", becauseSURFACE_LINEadmits one word-character group. And after widening that regex,resolveTypeNamereturns null for all 132 names, silently dropping theimport typeline from every one of those doc pages.Both halves of that table are now pinned as tests, so the rejected encoding cannot be reintroduced by someone who only sees the throw and widens the regex to make it go away.
check:docsis green on this branch with no regeneration, which is the artifact-level confirmation that the resolvers really did not move: not one reference page changed.Proof that the gate now SEES the removal
The previous lap ablated
origin/mainand proved the gate blind: deletingexport const RestApiRouteRegistrationwhile keeping its type alias left 17/17 shards byte-identical, the export total still 5277, andcheck:api-surfaceprinting "public API surface + factory signatures unchanged" at exit 0. This PR re-ran exactly that mutation on the fixed tree.Positive control first — on the fixed, unmutated tree:
A gate that is red for everything is not a fixed gate; this is what makes the red below mean something.
Mutated leg — same deletion,
pnpm --filter @objectstack/spec buildre-run, mutation proven on disk before anything was read (deleted-anchorgrep -c1 to 0, survivingexport typecount still 1, bloba6f98505…tob6fd374c…,dist/.build-input-hashe0550924…to07681a56…) and proven to have reacheddist/(ablation-dist-preflight … --absentprinted "marker absent from all 218 built files"):Restore leg, proven by state and not by an exit code: blob back to
a6f98505…,git diff HEADempty,dist/.build-input-hashback to the exact leg-0 valuee0550924e60ca64e, preflight present-modemarker present in 2 built files+working tree clean against HEAD, andcheck:api-surfacegreen again. The mutation script carriedtrap … EXIT INT TERMwith absolute paths resolved fromgit rev-parse --show-toplevel.The new pin, and proof it can fail
check:api-surfacecompares the generator against its own committed output, so it cannot notice the generator becoming less complete: revertkindsOfand regenerate, and it is green again on a surface that has silently dropped 132 value exports.api-surface-dual-kind-rows.pin.test.tsreads the committed shards and asserts the property directly.Reverse-validated by deleting the
EpochMs (const)row from the committed shard — the exact state a reverted generator would write:1 failed and 4 passed — including both negative controls, so this is a discriminating pin and not a blanket red. Restored and proven by blob hash plus an empty
git diff HEAD.The negative controls matter as much as the assertion: a generator that stamped every kind onto every name would satisfy "some name carries two kinds" too, so the pin asserts in the same parse that const-only names and type-only names both still exist, and that the aggregate is non-empty.
Changeset:
patch, and whyMeasured against what a consumer of the published package can observe:
dist/is byte-identical across this change; the TypeScript surface is exactly what it was.Name (kind)is unchanged, so anything that parsedapi-surface/before parses it now — including this repo's only in-tree parser, which already modelled a name as a set of kinds.That is a correction to the accuracy of a shipped record plus a strictness increase in this repo's own CI gate — a fix, not a feature.
minorwould advertise new API to a consumer reading the changelog, and there is none.One consequence for the release seat, stated because it is invisible in the diff:
build-spec-changes.ts --previous-surfaceis a release-time join, so a release crossing this change will list those 134 rows asaddedsurface entries inspec-changes.jsonand the upgrade guide. They are not new API — they are the same exports, newly recorded. The committedspec-changes.jsonis unaffected here because that flag is opt-in and was not passed.Verification
pnpm --filter @objectstack/spec build(3 runs, underscripts/pm/os-verify-lock.sh)VERDICT command-exit 0each timepnpm --filter @objectstack/spec test485 files / 13156 tests passed,VERDICT command-exit 0pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0(includescheck:scripts-typecheck, which compiles the editedscripts/)pnpm --filter @objectstack/spec check:generatedpnpm lint(eslint . --no-inline-config)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackpnpm check:dual-build-cjs-loads·pnpm check:type-check-debtPREREQUISITE NOT MET— both read a built monorepo closure and only@objectstack/specwas built here. NOT MEASURED, not a pass and not a finding; declared to CI, which builds the closure firstReview routing
Clause-②: yes.api-surface/is inpackages/spec's publishedfilesarray, and this makescheck:api-surfacereject a deletion it accepts today — accept/reject behaviour on a published artifact.needs:contract-reviewis hung on this PR and onapi-surface/records a name declared as BOTH a const and a type under(type)only — deleting the value half of any of 132 such exports is invisible tocheck:api-surface#15919.Generated by Claude Code