fix(scripts): split ablation-dist-preflight two readings so a DELETE ablation mutate leg can pass - #19640
Conversation
…E mutate leg can pass The dist reading and the tree reading ask about two different artifacts, and a bundler re-spells literals between them. They shared one marker and one exit code, so the two demanded mutually exclusive spellings: the emitted spelling made the dist reading true and the tree reading refuse a correct mutate leg, while the source spelling made the tree reading agree and the dist reading pass vacuously. - `--source-marker=<text>` names the spelling the SOURCE carries; the tree reading probes with it and the dist reading never sees it. - `classifyTree` grows a third leg. `restore` is now asserted only on a CLEAN tree; dirt the marker cannot explain is `indeterminate` and RED, with both readings and the remedy for each. Collapsing those two was the defect. - One exit code per question: 1 dist, 2 usage, 3 tree, 4 both. - Unknown options are a usage refusal; they used to be discarded in silence, so a mistyped `--absnet` ran the opposite mode. - Every `--absent` pass now states what it did not prove. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…lf-test Two new batteries, both on the roster and both floored: - `argv: the correct invocation is the only one` -- 11 parse cases and 5 exit-code cases, including the mistyped `--absnet` that used to be discarded in silence and the proof that the tree reading's code is not the dist one. - `the two-marker split: source spelling vs emitted spelling` -- a real git corpus replaying the measured shape end to end: the emitted spelling alone cannot see the mutation and is INDETERMINATE rather than a bogus restore verdict, `--source-marker` turns the same tree into a green mutate leg, and a leaked build artifact after a restore still refuses with the flag passed. Five pure-table cases and one real-git case move from leg `restore` to leg `indeterminate`, which is the split itself. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…test can see it `sourceMarker ?? marker` inline in `run()` is the whole split, and it was the one part of it no case could reach: an ablation that replaced it with `marker` left every pure-table case green while the card's repro went straight back to exit 3. `treeReadingMarker()` is that choice as a named export with three pins, one of which holds the coalescing nullish -- with `||` an empty source marker would fall back to the emitted spelling and rebuild the defect. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
|
| reading | value |
|---|---|
root package.json |
@objectstack/spec-monorepo, private: true, no files key |
where scripts/ lives |
the repo root, i.e. inside that private package — 399 tracked files under it |
| control — a package that IS published | @objectstack/lint, not private, files: ['dist', 'README.md', 'CHANGELOG.md'] |
⇒ no published package's files reaches the root scripts/ tree, and the package that contains it publishes nothing. The change cannot appear in any release.
⛔ Route 2 was not taken, and the gate's tie-breaker was not needed. The gate says "if you are unsure between routes 1 and 2, take route 2", because a wrong label is caught by review while a wrong empty changeset is caught by nobody. That tie-breaker governs uncertainty; this is a positive determination from the package boundary, not a coin-flip. ⛔ And an empty-frontmatter changeset is not an option in any case — newly added ones are rejected (#5471), because an all-empty set stalls the release silently and greenly (#4898).
Generated by Claude Code
Contract reviewServed-tier: 138/138
① Derived judgments1. Re-spelling premise — TRUE in its load-bearing half, FALSE in the half it prints to users. Measured, not accepted. No 2. Decoupling — it works. Reproduced end to end, both sides. Two fixture repos built (gitignored
3. Exit-code split — coherent, no caller broken. 4. ⭐ Presence pre-condition — the asymmetry is JUSTIFIED, not a hedge. The no-witness argument holds independently: 5. ⭐ Ablations — claim (1) VERIFIED and exact, and a second ablation found a real gap. Both in scratch copies; the head worktree stayed clean at 6. 7. Semver — 8. Design point, in the change's favour. Adding an optional flag rather than changing the documented invocation is forced and correct: ② Semver levelNone — no published surface moves. The sole changed file is dev-side agent tooling in no published package's ③ Boundary flagsA. A new user-facing message states a fact that is false for this repo's build. " B. The refactor traded defence in depth for a single point whose failure direction is a false green. Base's C. The one line that constitutes the fix is not covered. Severing D. ⛔ Nothing in CI runs this self-test — and this is the PR that most enlarges what that leaves unverified. Zero hits for the script name in E. Pre-existing header claim carried forward unverified. The header names F. Narrowings, declared. No Implemented-by: VERDICT: PASS Generated by Claude Code |
…Marker The split battery reimplemented `sourceMarker ?? marker` inline instead of calling the named export, so the one helper the previous commit added to make the marker choice visible was the one the battery did not use. This is the coverage half only; it is measured NOT to close the hole on its own -- see the commit that follows. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…re the marker choice once Three boundary findings from the contract review, all in this one file. A. The header and the indeterminate refusal both said tsup "drops the space after a colon". Measured on a real build of @objectstack/platform-objects with its own tsup config: dist carries `label: "Operator"` 19666 times and `label:"Operator"` 0 times -- the quotes are re-spelled, the whitespace is not. The no-space form is what --minify emits (the mirror counts, 0 and 19666), and none of the 21 tsup configs here sets it. The refusal is what an author reads while CHOOSING a spelling, so following it literally produced a marker this build never emits: 0 dist hits, which in --absent mode is a PASS. Both texts now state what was measured, name the minify caveat as the reason the claim is build-dependent, and send the author to grep dist/ instead. B. The clean branch trusted `leg === 'restore'` alone, so a broken inference printed "working tree clean against HEAD" with an empty dirty list at exit 0. The clean line now requires the inference AND `files.length === 0`; a disagreement between them refuses and names the paths. `classifyTree` is injectable so the guard has cases -- with the real inference the two can never disagree, and a guard no case can reach proves nothing. C. `run()` owned a second, untested copy of the marker-choice wiring. Severing it -- probing with the emitted marker, i.e. the exact pre-split defect -- left the self-test at 64/64 exit 0. Routing the split battery's readWith through treeReadingMarker() did NOT change that (measured: still 64/64): the helper was already pinned three ways, the CALL SITE was the gap. The reading is now one exported function, readTreeLeg(), which run() and both real-tree batteries call; the same sever now reds it. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
The `treeMarker` field no caller read was dead surface on a function added to remove a duplicate, which is the wrong direction. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 250/250
① Derived judgments1. ⭐ Flag C — the previous review's own one-line prescription genuinely does NOT close the hole. Verified in all three readings, and the reason is structural rather than an artifact of the sever chosen. Reproduced in isolated scratch copies of each commit's
The explanation holds and is independently provable from structure, not only from the measurement: the self-test never invokes ⇒ ⭐ The dev did not over-build — this is a genuine correction of a review prescription. The fix is the minimum change that makes the sever observable: at head 2. ⭐ Flag A — the clause was false, the re-measurement is exact to the digit, and the vacuous pass it caused is reproducible live. Real
An exact mirror; a byte dump confirms l-a-b-e-l-colon-SPACE-doublequote. Source: 1371 occurrences of ⭐ The clause was load-bearing, proved live on the built package. All three corrections verified, including the one the previous review did not name — the split battery's Judged on the stated criterion — is an author now led to 3. Flag B — a real defensive guard exercised by legitimate fault injection, ⛔ NOT manufactured coverage. Under the real classifier Measured: the guard is reached through the real path the moment the inference breaks, with no injection at all. Ablating the inference, then calling It also fails the 4. Counts and floors — tightened, nothing loosened. Pure table 26 → 29, the other four unchanged, 5. Gates — a DERIVED zero, derived independently here. 27 commands derived, same change set (517 changed lines, under the 5000 threshold). Reconciliation exit 0, "27 derived, 27 run, 0 NOT-MEASURED, 0 UNRUN", with the tool disclaiming "⛔ This tool ran none of them; it read the codes you recorded." Control: withholding one row flips it to exit 1 — the reconciliation bites rather than asserting. The staleness warning was read rather than ignored, correctly: the one commit touching 6. The two disclosed NOT MEASURED — prerequisite refusals confirmed, and one is MISLABELLED. Neither is in the derived 27. See ③-A. 7. Lint — reproduced, with a firing control. Whole-repo at head: exit 0, 6993 files, 0 errors, 0 warnings — the dev's figures exactly. Positive control: injecting a restricted import makes the same command exit 1; the file was restored and proved byte-identical by blob hash. 8. ② Semver levelNone — no published surface moves. All 83 manifests enumerated and parsed: 13 private, 70 published, every one declaring an explicit ③ Boundary flagsA. B. C. Narrowings, declared. The 13 Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19348
scripts/ablation-dist-preflight.mjs --absentanswered two independent questions with one marker and one exit code. Split, in the order this repo prescribes: delete the construct that permits the error, then make the correct shape the only spelling, and only last consider a check.The defect is one level down from where the card puts it, and that matters
The card says the tree limb "implements only the restore-leg reading -- it exits 1 whenever the working tree is dirty". Measured, that is not what the code did:
classifyTreealready derived a mutate leg, and a DELETE ablation whose marker occurs in the source passed. The card's premise holds -- a DELETE ablation's mutate leg could not pass its own pre-flight -- but the mechanism is one level down, and finding it is what makes the card's "second, independent trap" the same defect seen from the other side:The
dist/reading and the tree reading ask about two different artifacts, and a bundler re-spells literals between them.tsupemitslabel:"Operator"for the source'slabel: 'Operator'-- different quotes, no space after the colon. One string cannot be both. So the two readings demanded mutually exclusive spellings, and the only invocation that satisfied both at once was the one that proved nothing.Before -- reproduction, on
origin/mainat 34545d6The card's repro names
packages/spec/src/ai/skill.form.ts, which is fenced off for this task (every region ofpackages/spec/srcis held by an open PR). The equivalent, stated exactly and reproducible by anyone: a throwaway workspace whose source spells the literal with single quotes and whosedist/carries the double-quoted emission, carrying the unmodified script and its imports. A DELETE ablation drops thelabelentry; both files are then re-read on disk (grep -c: source 1 -> 0, dist 1 -> 0) before any verdict is taken.dist/readinglabel:"Operator"(what dist emits)label: 'Operator'(what the source spells)Run A is the card's reading, shape for shape. Run B is the card's second trap, and it is the same coin: the spelling that makes the tree reading agree is the spelling that makes the
dist/reading meaningless.The sharpest form of the before reading is this pair: a correct DELETE mutate leg (run A) and a genuinely unrestored tree (a restore leg with a leaked build-written baseline) print byte-identical tree verdicts and the same exit code. The instrument could not tell them apart.
The ordered remedy
1. Delete the construct that permits the error. Three constructs, all of them "one thing answering two questions":
--source-marker=SPELLINGnames what the SOURCE carries. The tree reading probes with it; thedist/reading never sees it. Omitted, the tree reading falls back to the positional marker -- correct exactly when the build does not re-spell, which is why every invocation in the three documents that state this script's usage keeps working untouched.classifyTreegrows a third leg.restoreis now asserted only on a CLEAN tree; dirt no marker explains isindeterminate. Collapsing "you are restoring" into "I cannot tell" was the defect: the failure of an inference is not a finding, and reported as one it told a correct mutate leg to restore the very mutation being measured.indeterminatestays RED -- refusing is the safe direction -- and the refusal now carries both readings with the exact remedy for each.A
--leg=mutateflag was considered and rejected: the script cannot check a claim, and a mistaken--leg=mutateat a real restore leg would switch off the leaked-artifact catch -- the one assertion here that has already recovered a measured run.--source-markerdemands evidence instead, and the control below shows it cannot be used to disarm that catch.2. Make the correct invocation the only spelling. Unknown options were discarded in silence --
argv.includes('--absent')plus astartsWith('--')filter -- so a mistyped--absnetran the OPPOSITE mode and printed a verdict that reads exactly like a real one. Unknown options are now a usage refusal;--source-markerhas exactly one spelling (joined with=, because with a space its value lands in the marker position); a blank value is refused; and a marker that itself begins with two dashes is now named rather than dropped. The wiring that chooses the tree reading's spelling is a named export,treeReadingMarker, because inline it was the one part of the split no case could reach.3. The presence pre-condition -- the ruling.
A dist-domain presence check is NOT added, and should not be. The reason is the clock, not an oversight:
--absentruns AFTER the rebuild, so the pre-mutationdist/no longer exists and nothing readable at that moment witnesses that the marker was ever in the artifact. Every surviving proxy is the SOURCE, whose spelling is -- by this very defect -- the one that differs. Such a check would fire on the correct invocation of the card's own repro (run A above), turning a fixed path back into a red one.A quote-and-whitespace-normalised near-miss probe was the one candidate that could fire, and it was measured against the card's own incident: on that tree it finds nothing, because the construct really did leave
dist/. It would not have caught the attempt the card describes. It buys only the narrow case "marker mis-spelled AND dist not rebuilt", at the price of a new false-red class on non-unique markers -- which this file's header already documents as the scan's known weakness. Rejected on that measurement.The presence pre-condition IS owed, and rung 1 pays it, in the domain where a witness survives. With
--source-marker, a DELETE ablation's mutate leg is green only when a tracked path had that literal at HEAD and lost it -- evidence that the construct existed and left -- while thedist/reading runs in the emitted spelling. Neither reading is vacuous, which is exactly what the measured first attempt lacked. And because the dist-domain half is a reading rather than a check, every--absentpass now prints what it did not prove and names where that reading is taken: this script in default mode on the PRISTINE build, before the mutation.After -- same corpus, same commands
dist/reading--source-markerwith the source spellingBoth directions of the control, because one direction proves nothing
--source-marker, exit 0, tree reading "mutate leg: 1 path differs from HEAD, of which 1 carries the marker".packages/demo/generated/baseline.json.--source-markerpassed: still exit 3, still names the baseline, and the message sharpens to "the--source-markeryou named ... is not the spelling that moved".dist/(label:"Value", 1 hit, known target inside the scanned radius of 3 text files) is RED on thedist/reading -- so the scan is reading those files, and the greens above are not a dead instrument.Red-first: two ablations, both taken from a committed tree, both restored to the HEAD blob
There is no
dist/mediation to prove here -- the subject is ascripts/*.mjsfile thatnodeloads from source, with no packageexportsbetween it and the runner -- so the pre-flight's own hazard does not apply to its own ablation. On-disk landing was proved both ways for each leg (injected text counted to 1, deleted text counted to 0) before any verdict was read.classifyTree's third leg removed. Self-test: 15 cases RED, including 6 of the 9 in the new split battery. End to end, a genuinely unrestored tree (a dirtygenerated/baseline.json) was reported "working tree clean against HEAD" at exit 0. Direction observed, stated as observed: this is a false green, which is more severe than main's false red --treeVerdict's clean branch is now keyed on the leg, so removing the third leg routes unexplained dirt into the clean return rather than into the restore refusal. Both are wrong; the pins fire on either.treeReadingMarkerreduced toreturn marker. Self-test: 2 cases RED (exactly the wiring pins), and the card's repro under the corrected invocation reverts to exit 3. This is the discriminating reading for the user-visible fix, and no pure-table case reaches it -- which is why the wiring is a named export.Restore was proved by state, not by exit code, on every leg: worktree blob equal to the HEAD blob,
git diff HEADempty, whole-treegit status --porcelainempty, and the injected text counted back to 0.Checks
node scripts/ablation-dist-preflight.mjs --self-test-- 64 cases, 5 batteries, all pass. Two new batteries, both on the roster and both floored, andSELF_TEST_BATTERY_FLOORraised 3 -> 5 so deleting one cannot silence it.scripts/pm/dispatch-gates.mjsderives for this path: all green, and all 27 were green on the pristine tree before the change too, so no verdict here is inherited.pnpm lint(eslint . --no-inline-config, the whole repo, 117s) exit 0 at this branch's head. Not a narrowed run, so no narrowing to justify. Its positive control: a code-shaped line injected inside a block comment in the changed file makes the same command exit 1 withcomment-swallow/no-code-inside-block-comment. Worth recording that the first control chosen -- an unused variable -- did not fire: only 2 rules resolve for ascripts/*.mjsfile here (no-restricted-importsand the comment-swallow rule),no-unused-varsis not configured, andparserOptions.projectis null, so type-aware linting is off.scripts/*.mjs;node --checkpasses and the file is.mjs, not TypeScript.Changeset
None, deliberately, and this is a departure from the dispatch word -- flagged rather than taken silently. The repo's criterion is whether a published surface moved. Measured: 70 published workspace packages, zero of which name
scripts/or this file infiles[](positive control: the same predicate fires ondistin@objectstack/spec'sfiles[]). So nothing releases. An empty-frontmatter changeset was written and then removed after readingscripts/check-empty-changeset.mjs, which rejects exactly that shape (#5471: an empty changeset is a real input tochangesets/actionand can take its "All changesets are empty; not creating PR" branch). The correct spelling here is theskip-changesetlabel, which is the PM seat's act -- I have added no label.Acceptance notes
Out of scope for this PR, recorded rather than fixed:
scripts/ablation-replace.mjsrestores from HEAD, not from the pre-mutation working copy, so running it over an uncommitted edit destroys that edit while printingok restored. Hit during this task: it printedblob 4f6d6941d531 -> 22713e9104d8going in andblob after restore 591ef264a023coming out, compared the latter only against HEAD, and reported success. A one-line comparison of the two blobs it already prints would have named it. The ablation discipline does require committing first, which is why this is a gap rather than a contradiction.scripts/ablation-replace.mjsline 538 prints the follow-up pre-flight command for a DELETE ablation using the source anchor as the marker. That is precisely the spelling that makes thedist/reading vacuous on any package whose build re-spells literals -- the tool hands the author the wrong half of the pair. Fixing it means teaching it--source-marker, which is its file, not this one..claude/skills/dogfood-verification/SKILL.mdas one of "three documents that state this script's invocation"; that file no longer mentions the script. Stale prose, no behaviour attached.Generated by Claude Code