test(cli): pin the SHAPES of the ratified hook-body surface, not only its names - #16043
Conversation
… its names `published-subpath-hook-body.pin.test.ts` read the packed `.d.ts` for exported names and star re-exports only, so three changes that break every consumer of the newly-public `@objectstack/cli/hook-body` surface left all four names in place and passed green: a signature change to any ratified export, a renamed field on `ExtractedBody`, and a member dropped from the `HookBodyRefusalKind` union. A conformance fixture is now compiled by a real `tsc` from the consumer directory, against the PACKED `.d.ts` reached through the `exports` map. It carries invariant type-identity assertions over all four exports plus a consumer limb that writes the ordinary thing, and a control per failure mode: `@ts-expect-error` directives that must fire, so an assertion that goes vacuous is reported as TS2578 rather than passing silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…ing its silence A clean tsc run and a tsc run that compiled nothing both print nothing and both exit 0, so "no diagnostics" is only evidence once the program is known to hold the fixture and the packed `.d.ts`. The run now asks for `--listFiles` and asserts the population before the silence over it is read: the fixture, the ratified entry and the internal module it re-exports are all present, and no file from this workspace is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Seat review — clause ② is NO on both limbs, so no tier review is owedRecording this so a later reader can tell the difference between a contract review that was skipped and one that was never owed. This is the second. Judged from the delivered diff, not from the PR body. The seat pulled the changed-file list: exactly one file,
What the seat verified before arming
Handed back rather than folded inTwo neighbouring surfaces were measured in the same pass and filed as their own cards rather than widened into this PR: #16045 ( Generated by Claude Code |
Fixes #15630
What passed green that should not have
packages/cli/test/published-subpath-hook-body.pin.test.tsread the packed.d.tsfor exported NAMES and star re-exports only (declaredExports()). It never looked at the shape behind any name, so three changes that break every consumer of the newly-public@objectstack/cli/hook-bodysurface left all four names in place and the pin stayed green through each:ExtractedBody;HookBodyRefusalKindunion.The last is the sharpest, as the card says: those members became a public type the moment the subpath was ratified, so removing one is a breaking change to a published union that the pin existing to hold this surface would not notice.
Route A, not route B — and why
Triage left the fork open and named the criterion: A catches "can a consumer still compile", B catches "did the shipped bytes change". This PR implements A — a conformance fixture compiled by a real
tscfrom the consumer directory, against the PACKED.d.tsreached through theexportsmap.node_modulesoutside the workspace and runs a child process against it. The shape limb is one more child in the same consumer directory, so it inherits the packed-not-source discipline the file was built around rather than re-deriving it.typescondition that stops resolving isTS2307in the fixture, exactly as it would be for a real dependent. B would not see it at all — the bytes it snapshots would be unchanged.All three of the card's failure modes are blocked, each with a positive assertion and a negated control. Nothing from finding 1 of the review is re-done here; that landed with #15611.
What the fixture is
switchin a dependent. Covered: the exact union members; the exact key set and whole shape ofExtractedBody; the exact signature ofextractHookBody; whatHookBodyExtractionErroradds toError, the types andreadonlyof those members, and its constructor parameters.@ts-expect-errordirectives over deliberately wrong expectations, one per failure mode. Each MUST error; a directive that stops firing is reported as TS2578. This is what keeps the assertions from going vacuous if the packed types ever resolve toanyor the identity check stops discriminating, and it carries the ablation below into CI permanently instead of leaving it in a PR body.tscrun and atscrun that compiled nothing both print nothing and both exit 0. The run asks for--listFilesand the population is asserted before the silence over it is read: the fixture, the ratified entry and the internal module it re-exports are all in the program, and no file from this workspace is.The fixture is a string written into the consumer directory, not a
.tsfile undertest/. A file there is compiled by this package's owntsconfig.test.json, where the same import resolves through the workspace to a build artifact — whichcheck:type-source-resolutionrefuses — so checking it in would answer a different question under the same name.The ablation — predicted before it was run, and what happened
One mutation batch on
packages/cli/src/hook-body.ts, carrying all three failure modes at once and moving no name: the union re-exported one member short,ExtractedBodyre-exported withisExpressionrenamed toisExpr, andextractHookBodyre-exported with its second parameter dropped. It was chosen so the package still type-checks and still builds, and so the runtime namespace is byte-for-byte the same two keys.main, under the mutationThe baseline row is the measurement the card is worth: every name assertion, the star-re-export count, the runtime key list, both resolution conditions and all three extractor behaviours pass while the published contract is broken in three places at once.
Diagnostics predicted and observed, class for class: TS2344 x6 (identity assertions), TS2578 x5 (controls that stopped firing), TS2554 x1, TS2339 x1, TS2322 x1 — 14, each mapped to its fixture line. One extra
TS2344in a naive grep of the log was vitest's truncated preamble, not a diagnostic.Discipline on both legs: the pristine entry was captured as a git blob with
git hash-object -wbefore any mutation and restored withgit cat-file blobagainst absolute paths; the mutation was proved on disk by a removed-text count and an injected-marker count, and proved in the built artifact byscripts/ablation-dist-preflight.mjs; the restore leg was proved by blob-hash identity, by a clean whole-treegit status --porcelain, by the same preflight in--absentmode, and only then by colour.One correction worth recording: the first attempt reported the new pin as RED when vitest had actually matched no test file (a repo-relative path handed to a runner whose cwd is the package). That is a measurement that never happened, not a red pin. The script now refuses to read any run whose log says "No test files found".
Clause 2, declared per limb from the delivered diff
The delivered diff is one file,
packages/cli/test/published-subpath-hook-body.pin.test.ts, 259 insertions and 1 deletion.dist,README.mdandCHANGELOG.mdonly, so nothing in the diff reaches a consumer. Nothing underpackages/spec/src/**is touched..d.tsthat is already published and is byte-identical before and after. The call is not close, which is why it is graded NO rather than YES.skip-changesetaccordingly: the PR publishes nothing from any package.Verification
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsagainst the real change set, and asserted against that tool's ownReconciliation — 44 famil(ies)line: 44 commands, 43 exit 0. The one non-zero ispnpm check:dual-build-cjs-loadsat exit 3 = PREREQUISITE NOT MET — it reads built output and eleven packages outside this card's build closure have nodist/. Its own text says "This is NOT a pass: nothing was measured", and it is reported here as NOT MEASURED, never as a pass. It names no path in this diff.check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjsexit 2 = NOT WIRED (they needPR_BODY/PR_NUMBER, supplied only by their workflows) and@objectstack/spec check:react-declaration-parityexits 1 stating "This gate did NOT run" — it needs an objectui manifest and a browser. All three are NOT MEASURED, none is a verdict about this diff.pnpm --filter @objectstack/cli typecheck— green, and its test layer ledger is unchanged at 3 files / 28 errors / 6 pinned signatures. The edited file is confirmed present in that program by--listFilesand contributes zero of those errors.pnpm lintover the whole repository — exit 0. Not narrowed; the full scan was run.test/vitest-tiers-partition.test.tstogether: 37 passed. The partition pin matters because the edit adds a child-process spawn, which is a tier signal; the file is still classifiedunit.All test runs and builds went through
scripts/pm/os-verify-lock.sh; the figures are shared-box seconds, not idle-box seconds.Handed back, not acted on
Measured while checking the card's own NOT MEASURED question about sibling surfaces, and deliberately left alone here:
@objectstack/speccarries the same class of gap at scale. Itsapi-surface/*.jsonledger pins 5309 exports across 17 entry points by name and kind, whileapi-surface-signatures.jsonpins 27 of them by signature hash — 0.5 percent. A signature change, a renamed interface field or a dropped union member moves neither the name nor the kind, so the other 5282 behave exactly like the surface this PR repairs.@objectstack/consoledoes not share it. It exports./package.jsonand nothing else, declares notypes, and has no type surface behind any name — the spelling is the same but there is nothing there to widen.@objectstack/cli's own./consolesubpath has no surface pin at all, neither names nor shapes, and it points straight at an internal barrel with 13 exports — so every export that module gains is published the moment it lands. The existing pin asserts that./consoleis a declared subpath, and stops there.So the card's open question has an answer: this is one fix, not a family — but item 1 is a real, separate finding about a different package and a different gate, and item 3 is a real gap inside this package that this card does not cover.
🤖 Generated with Claude Code
Generated by Claude Code