Uh oh!
There was an error while loading. Please reload this page.
fix(devx): make --require-stamp cover the spec side, so check:console-injection can't skip its only tree-sensitive assertion - #10597
Conversation
…-injection cannot skip its only tree-sensitive assertion check:console-injection has six failure verdicts; five are pure functions of the restored console dist and its stamp, and exactly one reads this tree — the probe-expiry re-check. That one needs packages/spec/dist, because readSpecBlob resolves the package's exports map. With the spec unbuilt, readSpecBlob throws ProbeError, the script catches it, treeBlob stays null, the expiry branch is skipped and the run exits 0 printing only an info line. --require-stamp exists precisely to refuse a vacuous pass, but it covered the dist side only. So a run could satisfy it while asserting nothing about the checkout it guards. ci.yml gets a readable spec today by STEP ADJACENCY — the Console Pin Gate happens to run turbo build --filter=@objectstack/client... first — not by any contract; reorder the steps and the assertion turns off silently while still reporting green. --require-stamp now also requires that every stamped staleness probe was actually re-checked. Scoped to a probe that exists and went unexamined, not to "the spec is unbuilt": a stamp recording no skew has no expiry question to skip and still passes. The bare invocation is unchanged, so a checkout with no built spec keeps the advisory notice. No ci.yml change: the requirement rides the flag the gate already passes, and a flag a new job could forget would reproduce the same failure. Deriving the probe from packages/spec source text instead was priced first and declined; the measurement is recorded in the file header. Refs #9710, #9706, #9667, #8134
os-zhuang
commented
Aug 21, 2026
PM review — ACCEPT, arming. Option 3 was priced honestly and the refusal to take it is the right call.Reviewed against the tree and the PR, not against the report. Head ⭐ The option-3 pricing is the substance hereMy brief told you to price the buildless route first and report the numbers even if you rejected it. You did, and the premise I handed you turned out to be false in exactly the direction that matters:
True for 98.5% of the probe pool. The 1.5% that is not (2.0% against the published
And you reproduced the counts against an independently built spec in a sibling worktree, so this is structural rather than dist/src skew. ⭐ Recording the measurement in the script header so the next asker does not re-price it is worth as much as the fix. On rejecting option 2 — I under-specified, you corrected itMy brief presented options 1 and 2 as near-equivalent. Your correction stands and is the sharper reading:
That is precisely the "step adjacency, not a contract" problem the card is about. A fix that re-creates the defect one level out is not a fix. Riding the flag the gate already passes means no What earns the ACCEPT
Your open question — A, ratified. |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10428
check:console-injectionhas six failure verdicts. Five are pure functions of the restored console dist and its stamp; exactly one reads this tree — the probe-expiry re-check:treeBlobcomes fromreadSpecBlob(specDir), which resolvespackages/spec'sexportsmap and therefore needspackages/spec/dist/**. With the spec unbuilt,readSpecBlobthrowsProbeError, the script catches it,treeBlobstaysnull, the expiry branch is skipped — and the run exits 0 printing anℹ.--require-stampexists precisely to refuse a vacuous pass, but it covered the dist side only, so a run could satisfy it while asserting nothing whatsoever about the checkout it guards.Honest scope: this is not live today.
ci.yml's Console Pin Gate runsturbo run build --filter=@objectstack/client...before the check and@objectstack/specis inside that closure. But that is step adjacency, not a contract — reorder the steps, or call this from a job that does not build the closure, and the only tree-sensitive assertion turns off silently while still reporting green. This PR converts the coincidence into a contract.Option 3 was priced first, and declined on measurement
The card's cheaper variant — derive the expiry probe from
packages/spec/src/**source text, making the assertion buildless — is strictly better than enforcing a dependency, so it was measured before anything was written. It does not hold. Its premise, that.describe()arguments are "plain string literals present in both", is ~98% true, and the missing 2% falls on the side that cannot be tolerated.Driving the real
describeCandidates()/readSpecBlob()over a freshly builtpackages/spec(dist blob 9,693,406 bytes; 595 non-test source files):src/**as literal text.zod.tssubsetEXPIREDon a healthy treeThe 44 split into two irreducible bundler transforms, both verified by hand:
25/44 — quote-and-escape normalisation. Source writes
'…definition\'s…'; esbuild re-emits"…definition's…". Same characters, different bytes, so a literal substring search over source misses it. (20 of the 25 have their pre-quote prefix present in source, confirming the text is there, only spelled differently.)19/44 — constant-folded concatenation. Source splits long descriptions across lines and the bundler folds them into one literal that exists in no source file. Concretely,
packages/spec/src/api/protocol.zod.ts:341-342:A missed probe here reads as "not expired" — a silent pass, the same failure class this gate exists to end. Symmetric src-vs-src derivation would cancel both channels, but that is a redesign of what
assert-console-spec-injection.mjsstamps, and it still cannot make the bundle side buildless, which is where the dist dependency actually lives. So the dependency stays and is made mandatory instead. The full measurement is recorded in the script header so the next person does not re-price it.The change
--require-stampnow also requires that every stamped staleness probe was actually re-checked. Deliberately scoped to a probe that exists and went unexamined, not to "the spec is unbuilt": a stamp recording no skew has no expiry question to skip and still passes. Exit 1, matching every other--require-stamprefusal (no dist, no stamp) — 2 stays reserved for a tree the script cannot read at all.No
ci.ymlchange, by design. The requirement rides the flag the gate already passes, so it cannot be forgotten — a new--require-spec-blobthat a future job neglects to pass would reproduce exactly the failure this card is about. The bare invocation is untouched: a checkout with no built spec keeps the advisoryℹ, because there the five bundle assertions genuinely do stand on their own.Validation
Four rows driven through the real exported
evaluate(), dist and stamp held fixed, only the tree varying. Both flag modes, because the card's table did not state one:Before
requireStamp: falserequireStamp: true(what ci.yml runs)PASSPASS— expiry check skippedPASSPASSPASSPASSFAIL(1)— EXPIREDFAIL(1)— EXPIREDAfter
requireStamp: falserequireStamp: true(what ci.yml runs)PASS(advisoryℹ, unchanged)FAIL(1)—✗ A stamped staleness probe was never re-checked for expiry.PASSPASSPASSPASSFAIL(1)— EXPIREDFAIL(1)— EXPIREDRow 1 changes verdict under the flag CI actually passes; rows 2-4 are untouched in both modes, and the bare mode is untouched entirely.
Reject side asserted positively. A "no error" test proves nothing here — before the fix this fixture exited 0 with an
ℹ, which is itself a green that asserts nothing. Self-test case 7b constructs the unbuilt-spec tree (makeUnbuiltSpecPkg: real manifest and exports map, nodist/) and asserts the red plus its branch-unique wording, alongside a positive control (built spec, same flag, still passes — without which the case would be satisfied by a script that fails--require-stampunconditionally) and a precision case (no-skew stamp on an unbuilt tree still passes).Ablation. Reverting only the new refusal (
if (expiryDeferred > 0)→if (expiryDeferred > 0 && !requireStamp)) turns the self-test red with exactly the predicted 3 failures, and no others:The mutation was confirmed on disk before the run (removed-text count 1→0, injected-text count 0→1, plus the diff hunk), and the restore leg was confirmed the same way (mutation text absent, original present, worktree clean against the commit). No build is involved — these scripts run from source, nothing resolves through a
dist/.Real-CLI control, against the actual built
packages/specrather than a fixture:--require-stampwith the real spec exits 0; the same dist and stamp with an unbuilt spec exits 1 with the new message.Gates, derived from the real change set via
node scripts/pm/dispatch-gates.mjs(not a recalled list) at357f67864b, all green:pnpm check:console-injection—✓ … --self-test: 27 assertions over real fixture trees (real evaluate() path)pnpm check:cross-package-test-inputs—All 60 self-test cases passed./OK: 12 package(s) read outside themselves…node scripts/check-nul-bytes.mjs—OK (scanned 6187 text file(s) … no raw ASCII control bytes)eslint scripts/check-console-injection.mjs --no-inline-config— exit 0Changeset
This PR carries no changeset and takes the
skip-changesetlabel instead. It changes one repo-internal CI gate script; the root package isprivate: trueand no published package shipsscripts/**, so it releases nothing. The gate's own error text names that as theskip-changesetcase, and explicitly rules out an empty-frontmatter changeset as an alternative.Out of scope
Filed #10595 (unassigned,
finding):--require-stampstill passes on a stamp whosepackagesarray is empty — the same "satisfies the flag while asserting nothing" shape, one input over. Not reachable from the real producer today, and whether it should refuse is a judgement call rather than something existing evidence pins, so it is recorded rather than fixed here.Refs: #9710 (where this was measured) · #9706 · #9667 · #8134
Generated by Claude Code
Generated by Claude Code