Skip to content

Commit d0b8ec2

Browse files
feat(scripts): an anchor-verifying ablation replacement helper, and the census that shaped it (#18598)
Fixes #18226 Clause-②: no `sed -i` / `perl -i` have failed silently twice in this repository, in two different modes, and neither was caught by an exit code. This adds `scripts/ablation-replace.mjs`: an ablation mutation goes through an anchor that must HIT, and the tool produces its own on-disk evidence instead of relying on the caller to remember to add some. Delivered in the order triage set (`5707288884`) and the card's own acceptance criterion 1 set: **the census first**, and the helper's shape decided by it. --- ## Deliverable 1 — the census ### Pathspec boundary, stated before the numbers **Enumerated:** every one of the **8,766 tracked files** of `objectstack-ai/objectstack` at `e0d05538c0d0275728d25acfafbab259e23d0710` (the merge base of this branch), via `git grep -nI` with no pathspec restriction. `-I` skips binary files. **NOT enumerated, and each for a reason that matters to the reading:** 1. **Untracked / ignored content** — `node_modules/`, `dist/`, `.turbo/`. Not ablation authorship surface. 2. **The sibling repositories** `../objectui`, `../cloud`. Different trees; `--repo` discipline forbids answering for them from here. 3. **Git history.** This checkout is shallow (`git rev-parse --is-shallow-repository` = `true`), and the card asks about 「今天树上」 — today's tree. No windowed history question was asked, so none was answered. 4. ⭐ **Agent transcripts — where BOTH recorded failures actually happened.** No tree query can reach them. This is not a gap in the method; it is the census's main finding, below. ### The counts, and what classified them Two probes, both over the whole tracked set: | probe | regex | raw hits | |---|---|---| | A | `sed` followed by an `-i` switch cluster | **45** | | B | `perl` followed by an `-i` switch cluster | **8** | Every hit was then classified — markdown prose / shell or JS comment / quoted fixture string / actually-executed command — by stripping comments, then stripping quoted segments, and re-testing: | class | count | what they are | |---|---|---| | PROSE (markdown) | 3 | `AGENTS.md:224`, `.claude/agents/os-dev.md:257` — the rules that name the hazard | | COMMENT | 6 | hook headers, `scripts/check-bash32-floor.mjs:54` | | QUOTED-FIXTURE | 42 | `.claude/hooks/guard-main-checkout-bash.selftest.sh` — command STRINGS fed to the guard hook as test input, never executed as `sed` | | EXECUTED | 2 | adjudicated by hand, both negative — see below | The two EXECUTED rows, read in context: - `.claude/hooks/guard-main-checkout-bash.sh:571` — inside the refusal message's `cat` heredoc. **Help text.** The classifier does not model heredocs; this is a known false positive, named rather than hidden. - `scripts/pm/os-verify-lock.sh:453` — a real `perl` invocation, but `perl -MTime::HiRes -e 1`: a clock probe, no `-i`, no in-place write. Probe B's regex matched the `i` inside `-MTime`. **The probe OVER-selects; it does not under-select** — three of its eight raw hits are this same over-match. ### ⇒ The reading > **ZERO `-i`-family invocations are EXECUTED anywhere on the tracked tree, > and therefore zero are used for ablation.** ### Firing controls — because a zero without one is a dead probe, not a reading **Control 1 (the classifier can say EXECUTED).** The identical classifier, same code path, fed two synthetic rows: ``` fake/ablate.sh:12 sed -i "s/OLD/NEW/" packages/core/src/x.ts -> EXECUTED fake/ablate.sh:13 perl -0pi -e "s/a/b/" packages/core/src/y.ts -> EXECUTED TOTAL=2 EXECUTED=2 ``` It fires. The in-tree zero is a property of the tree, not of a broken probe. **Control 2 (the regexes themselves reach the tree).** Probes A and B are not silent: 45 and 8 raw hits respectively. A regex that matched nothing at all would be indistinguishable from this result at the raw level, which is exactly why the classification pass is reported separately from the match pass. **Control 3 (the second zero — no committed ablation script exists).** Probing for the ablation script shape — `trap` on `EXIT` / `INT` / `TERM` — returns 29 hits, so the probe fires; every one of the 29 is a tempdir cleanup or a process kill, and **none restores a tracked source path**. Probing for the restore leg itself (`git checkout HEAD --` / `git restore --source=HEAD`) returns 13 hits: 2 inside `scripts/ablation-dist-preflight.mjs` (a verifier, not a mutator), 4 in rules prose, and **7 inside test-file comments narrating an ablation that was run in a transcript**. The firing control for this zero is `git checkout` taken the same way, which hits 8+ files. ⇒ **Not one committed file in this repository performs an ablation mutation.** ### What the census actually decided — and it is not what the card assumed The card asked "how many ablations on the tree use the `-i` family". The answer is zero, and the reason is structural rather than fortunate: - **212 tracked files carry an ablation narrative** (174 of them test files). Ablation is heavily, routinely used here. - **Zero of them are ablation scripts.** Every one is a *record*, in a comment, of an ablation performed ad hoc in a dev's shell and never committed. So the tree was never the population. Both recorded failures — 2026-08-20's NUL byte and 2026-09-14's silent no-op — were transcript commands, and the tree can neither count them nor prevent the next one. Three consequences, and each is a shape decision rather than an opinion: 1. **The helper must be a standalone CLI a human or agent invokes from a shell**, not a library some committed script imports. There are no committed scripts to import it. 2. **The "existing ones migrate when touched" half of the card's adoption boundary has an EMPTY migration set.** Nothing is being left un-migrated, and `⛔ 不许把既有消融脚本一次性全改` has nothing to forbid here. 3. **The census does NOT conclude the helper must be mandatory** — so the ⛔ stop-and-report clause is not triggered. It points the other way: with the real population living outside the tree, a required gate would have nothing to read, and the cost would be a manual floor bought for no coverage. --- ## Deliverable 2 — the helper `scripts/ablation-replace.mjs`, one new file, the shape the census indicates. ``` node scripts/ablation-replace.mjs --file PATH --anchor TEXT --replacement TEXT -- CMD... node scripts/ablation-replace.mjs --file PATH --anchor TEXT --delete -- CMD... node scripts/ablation-replace.mjs --file PATH --anchor TEXT --replacement TEXT --hold node scripts/ablation-replace.mjs --restore --file PATH node scripts/ablation-replace.mjs --self-test ``` Against the card's five requirements: 1. **The anchor must hit, or it refuses and exits non-zero.** Zero hits exits `3` with `ANCHOR MISS`; a count other than the declared one (default exactly 1) exits `3` with `ANCHOR AMBIGUOUS` and names `--expect N`. 2. **It produces its own on-disk evidence.** Occurrence counts of both anchor and replacement before and after, plus `git hash-object` before and after, printed on every leg — the caller does not have to remember. 3. **Restore is proven too.** Blob equal to HEAD's **and** an empty `git diff HEAD`. An empty hash reads as FAILURE, never as a match against another empty. Restore is spelled `git checkout HEAD -- ABS`, never the bare form, which restores from the index. 4. **Restores on `EXIT` / `INT` / `TERM`** (and `HUP`, and an uncaught exception) against an absolute path resolved once from `git rev-parse --show-toplevel`, armed **before** the write. 5. **`--self-test`, both directions** — see the table below. Three properties are structural rather than checks bolted on: - **Anchor and replacement are LITERAL argv strings.** No regex, no shell word-splitting, no interpolation anywhere in the substitution path. That is the whole of the 2026-08-20 failure removed rather than detected: `$/` is four characters here and cannot become anything else. A replacement carrying a control byte is refused before any write. - **The write is read back from DISK, not from the string just written.** A blob hash that did not move is `NOTHING LANDED` and a non-zero exit — the 2026-09-14 failure, which exited 0. - **Exit codes are a contract**: `0` ok · `1` the leg ran and its evidence refutes it · `2` usage · `3` anchor · `4` the restore could not be proven. In WRAP mode the wrapped command's own status **passes through untouched** — a red child is the ablation's reading, not the tool's failure — which is why the tool's own failure needs a code no child can mint. ### The boundary the card and triage drew, kept verbatim ⛔ **This helper is NOT a mandatory path.** No gate requires it, it is not wired into CI, and no existing ablation script was rewritten. New ablations use it; existing ones migrate when touched. Making it mandatory would be a new required gate — a manual floor — and that is the maintainer's call. The header says all of this in the file itself. ⛔ **`AGENTS.md` was not touched**, nor was anything under `.claude/`. Option A (a named trap in the byte-discipline clause) belongs to the skills seat. ### Scope, and what it deliberately does not do The restore proof is **per-path**. The whole-tree half already exists and is better at it: `scripts/ablation-dist-preflight.mjs` reads `git status --porcelain` over the entire tree precisely because a build between mutate and restore writes committed artifacts *beside* the file you chose. The two compose; ⛔ neither substitutes for the other, and `--hold` prints the preflight invocation alongside its restore command. --- ## Dogfood — the helper proving itself on a real ablation of its own code ⭐ Both directions, from a committed state (`a3ec1cd30`, clean tree). **Failure direction — anchor missing MUST exit non-zero and write nothing:** ``` $ node scripts/ablation-replace.mjs --file scripts/ablation-replace.mjs \ --anchor 'NO_SUCH_ANCHOR_deadbeef' --replacement 'x' --hold x ablation-replace: ANCHOR MISS — the anchor does not occur in the file. Refusing to run: ... nothing was written; the file is untouched at 80b45ea EXIT=3 blob before = 80b45ea blob after = 80b45ea ⇒ nothing was written ``` **Landing direction — ablate this helper's own zero-hit refusal and watch its self-test go red:** ``` control (un-ablated): ok ablation-replace self-test: all 49 cases pass EXIT=0 $ node scripts/ablation-replace.mjs --file scripts/ablation-replace.mjs \ --anchor 'if (anchorBefore === 0) {' --replacement 'if (anchorBefore === -999) {' \ -- node scripts/ablation-replace.mjs --self-test ablation-replace: anchor "if (anchorBefore === 0) {" x1 (before) ablation-replace: anchor hits 1 time(s), as declared ablation-replace: anchor x1 -> x0 ablation-replace: replace "if (anchorBefore === -999) {" x0 -> x1 ablation-replace: blob 96b8f64 -> 79dd2f798cd7863ba223db2a811d07e730617b83 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 96b8f64 -> 79dd2f798cd7 ablation-replace: running: node scripts/ablation-replace.mjs --self-test x the refusal names the anchor miss x ablation-replace self-test: 1 case(s) failed. ablation-replace: command exited 1 ablation-replace: blob after restore 96b8f64 ablation-replace: blob at HEAD 96b8f64 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (96b8f64) and `git diff HEAD` is empty ``` **Reported honestly: the ablation's reach was narrower than intended and the reading is stated as observed.** Disabling the zero-hit branch does not make a missing anchor pass — with `--expect 1`, a zero count then falls through to the `anchorBefore !== expect` branch and is still refused with the same exit code. So **1** case went red (the one asserting the refusal *names* the anchor miss), not five. That is a fact about the two refusals overlapping at `--expect 1`, and it is the message assertion — not a bare non-zero check — that carried the discrimination. Worth knowing before anyone reads a single red case as a weak ablation. ### ⭐ The dogfood found a real defect, in the tool, before it shipped Wrapping the helper's own `--self-test` is the obvious way to dogfood it — and the first attempt printed `ok ... all 42 cases pass` and `EXIT=0`, having performed **no ablation whatsoever**. Read off the *whole* argv, the `--self-test` belonging to the WRAPPED command hijacked the dispatcher: the parent ran its own self-test instead of the mutation. **The exact false green this tool exists to refuse, arriving through its own front door.** The flag is now read only from the argv before the `--`, and a WRAP battery pins it (with a stand-in child, because wrapping the real self-test makes the self-test spawn itself unbounded — which is how the bug was found in the first place). ### A second live instance, in the same shift Writing this file, a control-byte character class spelled with backslash-u escapes was **materialised into the raw bytes** by the editing tool. `grep` reported the new source file as binary; `check:nul-bytes` would have refused the push. That is the 2026-08-20 failure mode reproduced inside the tool written to prevent it. The class is gone: the byte test is arithmetic on code points, with no escape left to materialise, and the header records why. --- ## `--self-test`, both directions ``` ok ablation-replace self-test: all 49 cases pass, in both directions. EXIT=0 ``` | battery | cases | asserts | |---|---|---| | pure verdicts | 16 | counts, N-replacement, both anchor refusals, `NOTHING LANDED`, null-hash-is-FAILURE, control-byte fatality, restore verdict | | argument parsing | 9 | `--` splitting, bad `--expect`, no-op replacement, mutually exclusive flags | | direction A — anchor missing refuses | 5 | non-zero, code `3`, names the miss, **nothing on disk**, blob unmoved | | direction B — anchor present lands | 8 | on disk (read back), anchor gone, **blob moved**, both hashes printed, `--hold` prints its restore command | | restore is proven, never assumed | 4 | blob == HEAD, `git diff HEAD` empty, original text back | | WRAP holds the trap and restores | 7 | the child ran against the MUTATED file, its status passes through, restore proven, `--self-test` after `--` does not hijack | Batteries carry a pinned floor and a roster floor, so a battery that stops running names itself rather than going quiet. --- ## Changeset — `skip-changeset`, measured with a firing control ⚠️ Not the "scripts are always exempt" reflex. The criterion is whether anything **published** moves — each manifest's `files[]` actual shipped content — and the seat named `packages/lint` shipping `CHANGELOG.md` precisely so this could not rest on a reflex. A reachability predicate was built over the **70 publishing manifests** that declare `files[]` (zero of which declare an entry escaping its own package directory, so no `files[]` can reach repo-root `scripts/`), then run with controls: ``` THE DIFF scripts/ablation-replace.mjs -> NOT PUBLISHED FIRING CONTROL A (the named trap) packages/lint/CHANGELOG.md -> @objectstack/lint files[]: CHANGELOG.md FIRING CONTROL B packages/spec/dist/index.js -> @objectstack/spec files[]: dist FIRING CONTROL C (negative) eslint.config.mjs -> NOT PUBLISHED ``` The predicate fires on both positives, including the `packages/lint` trap. Repo-root `scripts/` is outside every `pnpm-workspace.yaml` glob. ⇒ nothing published moves ⇒ `skip-changeset`. --- ## Clause ② `Clause-②: no`, re-judged from the **actual diff** rather than carried from the dispatch: one new file under `scripts/`, no schema, no export, no accepted set widened anywhere. The diff agrees with the declaration. --- ## Acceptance notes - **The helper ships with no pointer to it from the ablation procedure.** The three documents that route agents to `ablation-dist-preflight.mjs` are `.claude/agents/os-dev.md`, `.claude/skills/dogfood-verification/SKILL.md` (both governed surfaces) and `packages/qa/dogfood/README.md` — and this seat's dispatched file surface is `scripts/`. Adding the pointer is a separate, differently-owned edit. Noted, not filed: an adoption-style tool nobody is routed to is adopted by whoever greps `scripts/`, and the two governed files are the ones that would fix that. - Noted, not filed: `.claude/hooks/guard-main-checkout-bash.sh` writes its refusal help text through a heredoc, which a comment-and-quote classifier reads as an executed command. Carrier: no PR touches it here; it cost one hand adjudication in this census and nothing else. --- ## Gates Derived, not guessed, and reconciled rather than recalled: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack ⇒ derived from objectstack-ai/objectstack at a3ec1cd; --repo checked against this checkout's origin remote — it holds ⇒ change set: 1 path vs merge base e0d0553 (three-dot), scripts/ablation-replace.mjs ⇒ 28 commands ``` All 28 were run to a captured exit code (redirect first, then `$?` — never across a pipe), and the ran-list was reconciled back with `--ran`. The complete per-command table, including anything the run could not measure, is in the `os-dev-report` comment on #18226 — it is the authority, and a family the run could not reach is recorded there as `NOT MEASURED` with its reason rather than as a pass. Families `dispatch-gates` itself declares outside that set and this run does **not** claim to have covered: the 53 artifact-roster families (their `silent` verdict is a fact about a list, never a clearance), the 11 wide-population families, the 1 path-scheduled CI job, and the 14 changeset-derived families — which do not apply, since the measured decision above is that this diff publishes nothing. Repo-wide scans (`pnpm lint` and the full `check:*` farm) are CI's run, not a debt of this branch. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d93400f commit d0b8ec2

1 file changed

Lines changed: 779 additions & 0 deletions

File tree

0 commit comments

Comments
 (0)