Skip to content

fix(devx): burn the KNOWN_IMPORT_UNSAFE ledger 10 -> 5 — a main() for the five scripts/ modules that ran on import - #11917

Merged
os-steve merged 5 commits into
mainfrom
claude/issue-10667-entry-guard-burndown-2
Aug 25, 2026
Merged

fix(devx): burn the KNOWN_IMPORT_UNSAFE ledger 10 -> 5 — a main() for the five scripts/ modules that ran on import#11917
os-steve merged 5 commits into
mainfrom
claude/issue-10667-entry-guard-burndown-2

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Part of #10667

Sitting 2 of the KNOWN_IMPORT_UNSAFE burn-down. Sitting 1 (#10704) took the ledger 35 → 10; this one takes it 10 → 5, on the five files that needed a real main() extraction rather than a wrap.

The card stays open — three conditional leakers and two fenced files remain.

The ledger

entries
after sitting 1 (re-read on origin/main at claim time, fd50e59e7)10
after this PR5

Removed, one commit each:

fileshapefix
scripts/pm/check-governed-prose.mjsran the gate, then process.exit()guard + deleted a dead workaround
scripts/checklist-select.mjsusage block + process.exit(2)mid-importmain() + selfTest()
scripts/check-release-page-status.mjsprinted its verdict inside importersmain()
scripts/check-error-status-conformance.mjswalked the scan root, printed the full reportmain()
scripts/check-query-options-erasure-ratchet.mjsre-exec'd the importer's processin-place guard + async main()

What the leak actually was, measured

A child process imports each file for its exports alone, with clean argv (target passed by env so nothing looks like a flag), and prints one sentinel after the import returns.

filebeforeafter
check-governed-prose.mjsexit 0, no sentinelsentinel only, empty stderr, exit 0
checklist-select.mjsexit 2, no sentinel, usage on the importer's stderrsentinel only, empty stderr, exit 0
check-release-page-status.mjssentinel present, but the gate's whole verdict printed firstsentinel only, empty stderr, exit 0
check-error-status-conformance.mjssentinel present after ~8s of another gate's reportsentinel only, empty stderr, exit 0
check-query-options-erasure-ratchet.mjsexit 0, no sentinel, stdout = the ratchet's own OK linesentinel only, empty stderr, exit 0, 0.5s

The last row is the silent-success direction the rule exists to remove: ensureStackHeadroom() re-execs and then calls process.exit(status), so importing that module replaced the importer's process with a fresh run of this gate — and the importer's caller reads exit 0.

⚠️ That probe was first run in a pristine node_modules-free comparison worktree, where it terminated on the missing-eslint prerequisite instead — a real termination, but not the one being claimed. It was re-run against the base version of the file in the tree that has node_modules, which is the row above.

CLI behaviour is byte-identical — the load-bearing proof

A main() extraction moves executable lines where sitting 1's wrap did not, so every path was compared byte-for-byte on stdout, stderr and exit code, against the pristine base tree at fd50e59e7:

filelegs compared
check-governed-prose.mjsdefault · --self-test
checklist-select.mjsdefault (usage, exit 2) · --self-test · all --json · area:approvals · all --include-blocked · nope.xxx (no-match exit 1)
check-release-page-status.mjsdefault · --self-test · both paths of check-release-section-coverage.mjs, the sibling that reads this module
check-error-status-conformance.mjsdefault · --self-test · --report · --update — for --update, the rewritten baseline artifact is byte-identical too, not just the stdout
check-query-options-erasure-ratchet.mjsdefault · --self-test

All legs cmp-clean. Each file's before-capture was also run twice and pinned deterministic before anything was edited.

The dead argv workaround — checked, not preserved

The card flagged check-governed-prose.mjs's module-scope process.argv mutation as "looks stale — worth checking rather than preserving". It is genuinely dead, and it is deleted:

check-governed-merges.mjs now guards both of its module-scope triggers on invokedDirectly = isEntrypoint(import.meta.url)main() at :1201 and its self-test at :1614. On an import path neither can fire, whatever argv says. Measured rather than reasoned: importing the sibling with --self-test planted in process.argv runs no self-test, prints nothing, and returns its 34 exports.

The mutation was never free — process.argv is process-global, so it edited the argv of whatever imported check-governed-prose.mjs, for the duration of the import. The dynamic import() itself stays, because scripts/pm/dispatch-gates.mjs reads that edge.

Two corrections to sitting 1's brief

  1. check-governed-prose.mjs did not need a main() extraction. Once the dead argv mutation goes, its entire import surface is one tail dispatch — it took the cheap inverted guard. Four of the five needed the extraction, not five.
  2. check:parse-guard is no longer unreachable for this diff. Sitting 1 recorded that the derivation cannot see import edges and the family had to be reasoned in. On this diff scripts/pm/dispatch-gates.mjs matched it directly (scripts/** gate source), and it is in the derived list below. It was run either way.

Deliberate non-changes

Inbound importers: zero

Swept from / import() / require() of all five basenames across scripts/, packages/, tools/, examples/, apps/, .github/ and root package.json. Every hit is a comment, a ledger line, package.json script wiring, or a readFileSync of the file's text (dispatch-gates.mjs reads check-governed-prose.mjs to extract watch hints — it does not import it). So removing the import side effect is a no-op for every existing caller, and the byte-identical CLI legs above cover the only way any of them is actually reached.

Gates

Derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (13 families), all run on the final commit 2ca16133a, each quoting its own verdict line. Working tree clean at that sha.

gateexitverdict
check:entry-guard0✓ … 105 of them inert on import (5 known-unsafe, ⛔ SHRINK-ONLY)
check:parse-guard0✓ check:parse-guard: 153 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 364 file(s) …
check:cross-package-test-inputs0OK: 16 package(s) read outside themselves, all declared …
check:error-status-conformance0✓ every derivable runtime status is documented, and every documented status is reachable.
check:pm-governed-prose0✓ check-governed-prose: 2 instruction surface(s) name all 5 registered governed surfaces …
check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 135/168 --filter occurrence(s) … resolve
check:release-page-status0check-release-page-status: OK — 2 GA major(s) in scope (v16, v17) …
check:query-options-erasure0✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none new
check:slot-lookup0✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
check-ci-filter-parity.mjs0OK: all 96 declared cross-package glob(s) (81 unique) are covered …
check-cross-package-test-inputs.mjs0OK: 16 package(s) read outside themselves, all declared …
check-release-section-coverage.mjs02 finding(s) — advisory, this run still exits 0 (pre-existing; byte-identical to the base tree)

Plus two the derivation did not name, run by reasoning:

gateexitwhy it was run anywayverdict
check:platform-checklist0its first half isnode scripts/checklist-select.mjs --self-test✓ checklist-select self-test: 17 cases pass. / check-platform-checklist: OK — 15 areas, 207 items
check:nul-bytes0any edit triggers itcheck-nul-bytes: OK (scanned 6631 text file(s) … no raw ASCII control bytes)

Every heavy run went through scripts/pm/os-verify-lock.sh. Exit codes were captured before any pipe, never through tail.

skip-changeset: this diff is dev tooling only — six scripts/** files, nothing published, no user-visible behaviour change, and the CLI output of every one of them is byte-identical.

Generated by Claude Code


Generated by Claude Code

…dead argv workaround
Its top level ran the whole gate and then called process.exit() — importing it
for its exported predicates terminated the importer mid-import, with exit 0. The
silent-success direction: a caller reading status got "clean" from a gate that
never reported.
The module-scope `process.argv` mutation that withheld `--self-test` from the
sibling import is deleted rather than preserved. check-governed-merges.mjs now
guards both of its module-scope triggers on isEntrypoint(import.meta.url) (main
at :1201, self-test at :1614), so the flag cannot reach either one on an import
path. Measured: importing it with `--self-test` planted in process.argv runs no
self-test, prints nothing, returns its 34 exports. The mutation was never free —
process.argv is process-global, so it edited the importer's argv too.
CLI byte-identical before/after on both paths (default and --self-test): stdout,
stderr and exit code all cmp-clean. Import probe with clean argv: sentinel only,
empty stderr, exit 0. check:entry-guard named the file STALE before its ledger
line was removed; ledger 10 -> 9, gate green.
…ms does not kill the importer
This one was the fatal shape in the ledger. Its top-level CLI ran on import:
importing it for `selectItems` — the pure resolver the checklist-test skill's
front half is built on — printed a usage block to the importer's stderr and
called process.exit(2) mid-import. The probe's own sentinel never printed.
The self-test block becomes selfTest(), the CLI block becomes main(), and both
run only under isEntrypoint(import.meta.url). No executable line changed other
than its enclosing scope; the process.exit() calls stay exactly where they were.
Verified byte-for-byte against the pristine base tree, since a main() extraction
moves executable lines where a wrap does not — stdout, stderr and exit code all
cmp-clean on six legs: default (usage, exit 2), --self-test, `all --json`,
`area:approvals`, `all --include-blocked`, and `nope.xxx` (the no-match exit-1
path). Import probe with clean argv: sentinel only, empty stderr, exit 0.
check:entry-guard named it STALE before its ledger line went; ledger 9 -> 8.
… stops running inside importers
The file exports eleven predicates, and check-release-section-coverage.mjs is a
real reader of that surface — it inherits this gate's scope floor and pins the
two equal in its own self-test. Unguarded, importing any one of those predicates
ran the entire release-page gate and printed its verdict, so a caller's output
carried another gate's OK line above its own.
The run block becomes main(); it and the self-test dispatch now sit behind
isEntrypoint(import.meta.url).
Byte-for-byte against the pristine base tree on both paths, plus both paths of
check-release-section-coverage.mjs — the sibling that reads this module — all
cmp-clean on stdout, stderr and exit code. Import probe with clean argv: sentinel
only, empty stderr, exit 0. check:entry-guard named it STALE first; ledger 8 -> 7.
…f running the derivation on import
The module exports the whole derivation — parseStandardErrorCodes,
buildConstantIndex, deriveRuntimeStatuses, deriveDoorMap, reconcile and the
message builders. Unguarded, importing any one of them walked the scan root,
read every non-test source file under it and printed this gate's full report
into the importer's stdout before the import returned. Measured on this tree:
the probe's own sentinel printed only after ~8s of another gate's output.
`walk` stays a module-scope function declaration — a declaration is not a side
effect, and the entry-guard rule does not reach one. Everything from
`const update` down becomes main(), behind isEntrypoint(import.meta.url).
Byte-for-byte against the pristine base tree on every code path this file has:
default, --self-test, --report, and --update — for --update, the rewritten
baseline artifact is byte-identical too, not just the stdout. All four legs
cmp-clean on stdout, stderr and exit code. Import probe with clean argv:
sentinel only, empty stderr, exit 0. STALE first; ledger 7 -> 6.
…s importer
The loudest entry in the ledger. Two separate import side effects, fixed two
different ways:
- `ensureStackHeadroom()` at module scope. It re-execs with --stack-size and
then calls process.exit(status), so importing this module REPLACED the
importer's process with a fresh run of this gate. Guarded IN PLACE rather
than moved into main(): its docblock's invariant is an ordering one ("re-exec
once, before any linting -- including before --self-test"), and leaving the
call at its original position in module order is what keeps that ordering
checkable by reading rather than by re-deriving it.
- The tail becomes async main(), with the self-test dispatch, behind the guard.
No module-scope declaration between the two constructs an ESLint instance (all
three `new ESLint` sites are inside functions), so nothing heavy moved across
the re-exec point.
checkHeadroomAdoption() still reads this gate as armed: it tests
/ensureStackHeadroom\s*\(/ over comment-stripped source, which an indented call
satisfies.
CLI byte-identical before/after on both paths — stdout, stderr and exit code —
captured through scripts/pm/os-verify-lock.sh. Import probe with clean argv now
returns in 0.5s with the sentinel only and empty stderr, where before it never
returned at all. STALE first; ledger 6 -> 5.
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@os-steve
os-steve marked this pull request as ready for review August 25, 2026 00:38
@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT. Flipped out of draft (scripts/** is not governed); arming once CI is green. Verified against the branch and origin/main, not read back from the report.

The ledger, exactly as scoped

Main held 10; this removes exactly 5, and the 5 left standing are precisely the ones the dispatch fenced:

 'scripts/check-changeset-no-major.mjs', ← #9465 fence, untouched
'scripts/check-empty-changeset.mjs', ← #9465 fence, untouched
-'scripts/check-error-status-conformance.mjs',
-'scripts/check-query-options-erasure-ratchet.mjs',
-'scripts/check-release-page-status.mjs',
-'scripts/checklist-select.mjs',
'scripts/objectui-range.mjs', ← conditional leaker, untouched
-'scripts/pm/check-governed-prose.mjs',
'scripts/qa/qa-rollup.mjs', ← conditional leaker, untouched
'scripts/ts-parse.mjs', ← conditional leaker, untouched

Zero additions — shrink-only held. Both fences verified by name against the changed-file list (0 hits each; control: checklist-select = 1). 6 files, 5 commits, one per subject file.

⭐ You corrected my dispatch, and you were right

I wrote that all five need a real main() extraction. Four did. scripts/pm/check-governed-prose.mjs did not — once the dead module-scope process.argv mutation came out, its whole import surface was one tail dispatch and the cheap inverted guard sufficed. I checked rather than took it:

  • On origin/main the mutation is real and at module scope — :87const realArgv = process.argv; / :88 the filtering assignment / :90 the restore.
  • On your branch those module-scope lines are gone; process.argv now appears only inside a function (:349), with the rest of the hits being comments explaining the removal.
  • The justification holds: check-governed-merges.mjs imports isEntrypoint (:356) and binds invokedDirectly (:974), so the sibling really is guarded now and the workaround really is dead.

Deleting it rather than preserving it was the right call — the card said it "looks stale … worth checking rather than preserving," and you checked. process.argv is process-global, so that workaround was editing state it did not own.

The check-query-options-erasure-ratchet.mjs finding is the one I'd want a future sitting to remember: importing it re-exec'd and process.exit()'d, replacing the importer's process with a fresh run of the gate and returning exit 0 with no sentinel — the silent-success direction, which is the worst shape in this whole class because it is indistinguishable from a clean import.

Two methodology notes

⚠️A caution for the next reader, from my own control. I checked isEntrypoint in ts-parse.mjs as a positive control and got 2 — on a file still on the ledger. That is not a contradiction and must not be read as one: being on KNOWN_IMPORT_UNSAFE means the file leaks on import, and ts-parse.mjs leaks conditionally on an env var, so it can carry a guard and still belong there. "On the ledger" ≠ "has no guard."

Good catch on parse-guard: sitting 1 recorded it as unreachable-by-construction and reasoned it in; on this diff dispatch-gates.mjs matched it directly via the scripts/** gate source. Recording that as a correction rather than silently inheriting sitting 1's note is exactly right.

Also correct: re-running the ratchet's BEFORE probe after the first attempt terminated on a missing-eslint prerequisite in a node_modules-free tree. That first run was a real termination but not the claimed one, and reporting the re-run instead of the convenient first reading is the difference between a measurement and a coincidence.

The card stays OPENPart of, sitting 3 owns the remaining 5 (and 2 of those stay fenced until #9465 lands).


Generated by Claude Code

@os-steve
os-steve added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 200fc82Aug 25, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-10667-entry-guard-burndown-2 branch August 25, 2026 01:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-steve@claude