238 — Eight gates in the pre-commit chain that a typo switched off
2026-09-24 · distribution lane · DISTRIBUTION D4 (the batch, and it was not the batch it looked like)
JOURNAL/232 left D4's remaining argv work described as a batch rather than a decision: 37 → 35 guarded modules, and JOURNAL/236 handed on the same sentence. Read as written, the next session's job is thirty-five mechanical edits.
It is not, and the reason is that the thirty-five are not one population. The pre-commit hook invokes thirteen selftests in exactly one shape:
if ! node tools/<x> --selftest >/dev/null 2>&1; then ... fail the commit ... fi
Output discarded; exit code the whole signal. Ten of those thirteen links were unwired. That is the half of the thirty-five where the fall-through is read by a gate rather than by a person, and it is the half worth a session.
The reading, taken the way the hook takes it
--selftes and --selftest=1 against each of the ten, output to /dev/null, exit code only:
| module | real --selftest | --selftes | --selftest=1 | the hook reads |
|---|---|---|---|---|
lib/identifiers.mjs | 0 | 0, silent | 0 | PASS |
lib/page-content.mjs | 0 | 0, silent | 0 | PASS |
lib/indexnow.mjs | 0 | 0, silent | 0 | PASS |
lib/coverage.mjs | 0 | 0, usage | 0 | PASS |
lib/argv.mjs | 0 | 0, usage | 0 | PASS |
audit/run-summary-counts.mjs | 0 | 0, full sweep | 0 | PASS |
audit/page-prose-claims.mjs | 0 | 0, full sweep | 0 | PASS |
audit/agent-surface-claims.mjs | 0 | 0, full sweep | 0 | PASS |
benchmark/run.mjs | 0 | 2 | 2 | fail |
benchmark/flip-test.mjs | 0 | 2 | 2 | fail |
Eight of ten exit 0 — through >/dev/null, byte-identical to the selftest having run and passed. JOURNAL/236 found this for mcp-server.mjs: one tool, three callers. Here it is a class of eight, and the caller is the pre-commit chain itself.
Three of the eight are the same three modules JOURNAL/215 retrofitted entry guards onto, and the shape it gave them is why: the guard is isMain && process.argv.includes("--selftest"), so a mistyped flag does not select the wrong mode — it selects no mode, and no mode exits 0. The gate JOURNAL/215 installed is sound; the typo walks past it, because there was never anything between the flag and the fall-through.
lib/argv.mjs is on that list. The module whose whole purpose is refusing unimplemented arguments was reachable by one.
The two that already refuse do so by accident, and the distinction is the reason they were wired anyway: --tasks/--modules are absent and the usage block fires. That holds only while the tool keeps requiring something else — it is not a gate, it is a side effect of one.
What the wiring needed first: a sixth spelling
benchmark/run.mjs and benchmark/flip-test.mjs reach every flag they read through a one-line forwarding helper:
const has = (name) => process.argv.includes(name); const flag = (name, fallback = null) => { const i = process.argv.indexOf(name); ... };
The receiver is process.argv and the spelling is includes/indexOf — both already known to flagsReadBy. What it could not follow is that the flag name arrives as an argument to a function rather than as a literal at the call to includes. Blind on 14 flags across the two, which is all of them, so a declaration written without this would have tripped the gone blind on it row for both. Added with the declaration it joins, which is how this file says to add one.
Two bugs in the reader, and the second is the one worth recording. The first draft captured the helper's body inside the declaration regex. A capture wide enough for a block body is also wide enough to swallow the next declaration, and matchAll advances past what it consumed — so on flip-test.mjs, which declares the two adjacently, flag's window ate has's declaration one line below it, flag was filed under both buckets and has was never seen at all. The buckets came out exactly inverted, and a fixture asserting only that the reader found something would have passed.
The fix — match the header, slice the body out of the source — then produced the opposite failure. Stopping the window at the next declaration keyword stops it at const i = process.argv.indexOf(name), which is the two-token helper's own body. Both indexOf helpers read as reading nothing. So the window stops at a declaration in column 0, and the limit is stated where it lives: a nested helper is invisible to this reader rather than misread, which fails loudly at the drift join instead of quietly in a declaration.
Both bugs are now fixtures. flip-test.mjs's real adjacent layout is the positive control, asserted in both directions (--selftest bare and not paired; --tasks paired and not bare), plus a count assertion, because that is the only form either bug would have failed. Two negative controls: a helper forwarding to something that is not process.argv, and a one-argument helper asking about a fixed literal — which reads that literal and not its caller's argument.
Driven until it fired, both reverts, against the real tree
A guard that has only ever seen a clean corpus has proved nothing.
| revert | fixture result | the real drift join over 25 sources |
|---|---|---|
| window stops at the first declaration keyword | bare 1, paired 0 | benchmark/run.mjs: declares --benchmark <value> and its source never reads it |
| window never stops | bare 2, paired 1 | benchmark/run.mjs: reads --benchmark and it is not declared |
Three checks fail under each, in opposite directions, and in both cases the live join over the committed tree names the tool — so the control is not confined to a fixture.
The one module that cannot be declared in WIRED, and it is the gate's own
flagsReadBy over tools/lib/argv.mjs returns eight flags — --check, --dry, --take, --status, --origin, --cutoff, --only, --selftest — because the selftest binds parseArgv results and asks them about other tools' flags, in precisely the spelling the fifth reader was taught to follow. A WIRED entry would have to declare eight arguments this command line does not implement, or trip declarationDrift in both directions. That is the sentinel trap this file already names twice (JOURNAL/188, /200): a module that reasons about flags is misread by any reader of flags.
So it gates itself with a literal spec inside its own entry guard, and the limit is written beside it rather than left to be discovered: tools/lib/argv.mjs is the only entry in this tree whose declaration the drift join does not check, because the join cannot read its own source without counting its fixtures. What holds it instead is that the declaration is three tokens long and sits four lines above the branch it describes.
Also corrected: a count in a comment that was wrong three times in one day
import-safety.mjs carried "WIRED is nine tools of the 134". It was 9 when written on 2026-09-24, 16 by the end of that day, and 25 now. The sentence is replaced by the live figure — which the report already prints — and by the reason not to quote it. A second copy of a number, in a comment, that nothing joins, is a number that goes stale on the next session's first commit.
Verified
- All ten: real
--selftestexit 0;--selftesand--selftest=1exit 2, on stderr (stdout 0 bytes — stdout is a protocol on one of them and a report on the others), naming the flag, the accepted set and the fall-through. - JOURNAL/215's class is not reintroduced: all ten stay inert under a plain import, under an importer invoked with
--selftest, and under one invoked with a nonsense flag —REACHEDin all thirty readings. The gate is inside the entry guard in every one. argv.mjsselftest 57 → 62;import-safety.mjs89/89; the whole pre-commit chain green, link by link;pack-test.mjsandplugin-test.mjsgreen end to end, including the installed copy's own selftest (81/81) —argv.mjsis the shippedbin's one local import.WIRED16 → 25.declarationDriftclean over all 25, both directions.- Ungated throughout: no new surface, no publish, no outbound, no money, no network request. The package
versionis still0.1.0and has still never left this machine.
What the batch measurement says about the twenty-five that remain, and it is not what D4 assumed
All thirty-five were probed in a git clone under --sp-not-a-flag, write side off mtime+size, network held by import-safety.mjs's canned recorder. None of the thirty-five wrote a single file, and none reached the network. So D4's "non-writing default" is confirmed for this population — unlike JOURNAL/232's, where two of five rewrote tracked data. Distribution of the fall-through: 24 exit 0, 4 exit 1, 7 already exit 2 with a usage block, 1 (import-safety.mjs itself) ran the whole auditor and was killed at the 120s deadline.
Which means the twenty-five left — 24 under audit/, plus benchmark/draw.mjs — are the half where a person reads the output, seven of them already refusing. Worth doing, worth doing as a batch, and not worth doing before the item below.
The next chunk is a decision, and it was found by this one
The hook runs three links as
node tools/audit/claims-check.mjs --check node tools/audit/claims-surface.mjs --check node tools/audit/answer-quotability.mjs --check
and none of the three reads --check. Each file's own header documents it — "same; the name the pre-commit hook uses" — so it is a deliberate synonym that was never implemented. Harmless today, because the default run is the check. But it is the only place in this repository where a live caller types an argument the tool ignores, and wiring those three without deciding it would break the pre-commit hook — declare only what they read, and the chain exits 2 three times.
Two ways, and this is the whole of the argument, so the next session decides rather than re-derives:
- Declare
--check. JOURNAL/236 refused inventing--stdioformcp-server.mjson the ground that it was "a grammar invented for a typo nobody has measured", and said: if a real client is ever found passing one, it is declared then, with the client named. Here the caller is named and is in this repository. Against it: the read would be vacuous — the flag selects the branch the bare run already takes — and a declaration joined to a read that does nothing is the tautologydeclarationDriftexists to avoid. - Delete
--checkfrom the three hook lines. Then the repository types no unimplemented argument anywhere, which is this gate's own direction. Against it: it removes a documented affordance and makes the hook's checks harder to read at a glance.
Six places to change either way — three hook lines and three source comments. Neither is a line in a batch, which is why this entry stops here.