Skip to content

test(cli): pin the SHAPES of the ratified hook-body surface, not only its names - #16043

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-15630-published-dts-shape-pin
Sep 5, 2026
Merged

test(cli): pin the SHAPES of the ratified hook-body surface, not only its names#16043
os-litant merged 2 commits into
mainfrom
claude/issue-15630-published-dts-shape-pin

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15630

What passed green that should not have

packages/cli/test/published-subpath-hook-body.pin.test.ts read the packed .d.ts for 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-body surface left all four names in place and the pin stayed green through each:

  • a signature change to any of the four ratified exports;
  • a renamed field on ExtractedBody;
  • a member dropped from the HookBodyRefusalKind union.

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 tsc from the consumer directory, against the PACKED .d.ts reached through the exports map.

  1. A answers the contract question; the card is about a contract, not about churn.
  2. B's known failure mode is that people accept the new snapshot when it reds. A cannot be discharged that way: to make it green you have to state the new shape as a type, which is the reviewable act.
  3. A is nearly free here. The file already packs the package, unpacks it into a throwaway node_modules outside 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.
  4. A also covers a mode the card does not list: a types condition that stops resolving is TS2307 in 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

  • Assertions — invariant type identity against the ratified shape, so a widening reds exactly as loudly as a narrowing. An assignability check would let every widening through, and a widening on a published union is the half that breaks an exhaustive switch in a dependent. Covered: the exact union members; the exact key set and whole shape of ExtractedBody; the exact signature of extractHookBody; what HookBodyExtractionError adds to Error, the types and readonly of those members, and its constructor parameters.
  • A consumer limb — a function a real dependent would write, compiled for real. The assertions answer "did the shape move"; this answers the question the shape exists for.
  • Controls@ts-expect-error directives 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 to any or the identity check stops discriminating, and it carries the ablation below into CI permanently instead of leaving it in a PR body.
  • An anti-vacuity assertion, ordered first — a clean tsc run and a tsc run that compiled nothing both print nothing and both exit 0. The run asks for --listFiles and 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 .ts file under test/. A file there is compiled by this package's own tsconfig.test.json, where the same import resolves through the workspace to a build artifact — which check:type-source-resolution refuses — 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, ExtractedBody re-exported with isExpression renamed to isExpr, and extractHookBody re-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.

limb predicted observed
pin as it stands on main, under the mutation GREEN, 13/13 GREEN, 13 passed (13)
pin in this PR, same mutation, same build RED on the conformance limb only 1 failed, 14 passed (15) — the failure is the conformance test; the anti-vacuity test stayed green
restore leg back to green 15 passed (15)

The 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 TS2344 in 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 -w before any mutation and restored with git cat-file blob against absolute paths; the mutation was proved on disk by a removed-text count and an injected-marker count, and proved in the built artifact by scripts/ablation-dist-preflight.mjs; the restore leg was proved by blob-hash identity, by a clean whole-tree git status --porcelain, by the same preflight in --absent mode, 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.

  • Mechanical floor — NO. No new key on any published payload: the file is a test, and this package publishes dist, README.md and CHANGELOG.md only, so nothing in the diff reaches a consumer. Nothing under packages/spec/src/** is touched.
  • Conformance limb — NO. Nothing is re-selected between two already-published verdicts on a shipped face. No runtime code changes; no acceptance or refusal boundary moves; the diff only adds assertions over a .d.ts that 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-changeset accordingly: the PR publishes nothing from any package.

Verification

  • Gate union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands against the real change set, and asserted against that tool's own Reconciliation — 44 famil(ies) line: 44 commands, 43 exit 0. The one non-zero is pnpm check:dual-build-cjs-loads at exit 3 = PREREQUISITE NOT MET — it reads built output and eleven packages outside this card's build closure have no dist/. 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.
  • The Artifact rosters block was run separately, as it sits outside that total by the tool's design: 37 commands, 34 exit 0. check-partof-closing-keyword.mjs and check-single-claim-paths.mjs exit 2 = NOT WIRED (they need PR_BODY / PR_NUMBER, supplied only by their workflows) and @objectstack/spec check:react-declaration-parity exits 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 --listFiles and contributes zero of those errors.
  • pnpm lint over the whole repository — exit 0. Not narrowed; the full scan was run.
  • The shape pin and test/vitest-tiers-partition.test.ts together: 37 passed. The partition pin matters because the edit adds a child-process spawn, which is a tier signal; the file is still classified unit.
  • Every exit code above was captured before any pipe.

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:

  1. @objectstack/spec carries the same class of gap at scale. Its api-surface/*.json ledger pins 5309 exports across 17 entry points by name and kind, while api-surface-signatures.json pins 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.
  2. @objectstack/console does not share it. It exports ./package.json and nothing else, declares no types, and has no type surface behind any name — the spelling is the same but there is nothing there to widen.
  3. @objectstack/cli's own ./console subpath 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 ./console is 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

… 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
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@os-litant os-litant added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Sep 5, 2026 — with Claude
@github-actions github-actions Bot added the tests label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2e357650306335f7fdb8939d16edce4c96b48831packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

Seat review — clause ② is NO on both limbs, so no tier review is owed

Recording 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, packages/cli/test/published-subpath-hook-body.pin.test.ts, +259/−1.

  • Mechanical floor: no. No new key on any published payload — the diff adds no key to anything the platform emits. Nothing under packages/spec/src/** is touched. The exports map, the packed .d.ts and every source file are untouched; the fixture the diff adds is a STRING written into a throwaway consumer directory at test time, never a shipped artifact.
  • Conformance limb: no. Nothing re-selects an input class between two published verdicts. The diff only observes the shipped face — it compiles a consumer against the packed types and asserts what is already there. An observer of a contract does not move it.

needs:contract-review was therefore never applied to this PR or to card #15630, and none is being stripped.

What the seat verified before arming

  • The card's premise was reproduced before anything was changed. With three shape mutations applied to the ratified surface at once and no name moved, the pin as it stands on main passed 13/13. That is the defect, demonstrated rather than argued.
  • The anti-vacuity control runs FIRST and is the right shape. --listFiles establishes the program before its silence is read, for the reason stated in the file itself: a clean tsc run and a run that compiled nothing both print nothing and both exit 0. The first assertion pins that the program contains the fixture, contains the packed dist/hook-body.d.ts, contains the packed dist/utils/extract-hook-body.d.ts, and contains no file from this workspace — so a green on the second assertion cannot be a green over an empty program or a green over the source tree.
  • The ablation was predicted in writing first and matched. Baseline 13/13 green; the new pin red on the conformance limb only, with the anti-vacuity limb staying green; diagnostic classes TS2344 ×6, TS2578 ×5, TS2339 ×1, TS2554 ×1, TS2322 ×1 — each mapped to a fixture line. Predicted equals observed.
  • The restore leg is sound. Pristine bytes captured with git hash-object -w, restored with git cat-file blob under an EXIT INT TERM trap on absolute paths, proved by blob-hash identity, an empty whole-tree git status --porcelain, and a preflight reporting the marker absent from all 488 built files — and only then by colour.
  • A NOT MEASURED that was correctly refused to be read as a red. A first attempt read the new pin as RED when vitest had in fact matched no test file at all (repo-relative path against a package cwd). That is NOT MEASURED, not a failing pin, and the harness now refuses any log containing No test files found.
  • Gate union 44/44 derived and asserted against the tool's own Reconciliation — 44 famil(ies) line; 43 exit 0, one exit 3 PREREQUISITE NOT MET reported as NOT MEASURED by name and naming no path in this diff. Artifact rosters run separately: 37, 34 exit 0, 3 NOT MEASURED by their own verdict lines.
  • CI at head: 40 checks, 0 failure, Lint & Repo Gates among the successes.

skip-changeset is correct here: the diff is test-only and publishes nothing.

Handed back rather than folded in

Two neighbouring surfaces were measured in the same pass and filed as their own cards rather than widened into this PR: #16045 (@objectstack/spec pins 5309 exports by name-and-kind and only 27 by signature) and #16046 (@objectstack/cli's ./console subpath has no surface pin at all). A third measurement is a negative result recorded in both: @objectstack/console does not share the gap.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 5, 2026 22:05
@os-litant
os-litant enabled auto-merge September 5, 2026 22:05
@os-litant
os-litant added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 92d631f Sep 5, 2026
42 checks passed
@os-litant
os-litant deleted the claude/issue-15630-published-dts-shape-pin branch September 5, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] published-subpath-hook-body.pin.test.ts pins exported NAMES, not SHAPES — a signature change to a ratified public type passes green

2 participants