Skip to content

check-entry-guard: reject an exporting scripts/ file whose top level runs on import - #10665

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10631-entry-guard-class
Aug 21, 2026
Merged

check-entry-guard: reject an exporting scripts/ file whose top level runs on import#10665
os-zhuang merged 2 commits into
mainfrom
claude/issue-10631-entry-guard-class

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Part of #10631. Verified at 4a83b1a3db.

The card asked whether the gate-level answer beats a fourth one-at-a-time fix. It does, and the
measurement is what decides it — including the fact that the card's own population of 39 is keyed
on the wrong property.

What changed

One file. check:entry-guard grows a second finding kind: a scripts/** file that exports a
binding
must have every top-level statement that runs something behind the guard. The spelling
rule above it is untouched, and so are its 26 self-test cases — including the one the card quotes,
'a file with no guard at all is accepted', which stays true and correct for the 42 pure CLIs here
that export nothing.

Re-derived population (worktree at origin/main9dd192d48b)

scripts/ files scanned123
with a real (code, not comment) entry guard42
exporting bindings AND unguarded39 — matches the card

The exceptions, measured before the rule was chosen

The card asked whether "a file that exports must carry a guard" is true of all 39. It is not — 7
are legitimate exceptions
, and they cost zero allowlist entries because the rule is shaped so it
never reaches them.

Every exporting scripts/ file was imported in a child process for its exports alone, with a
trailing ALIVE marker (the #10628 instrument):

populationimporter diestool runs, loudsilent
42 exporters with the guard0033
39 exporters without the guard8147

(9 and 10 respectively could not be loaded without node_modules and are outside the columns.)

The 7 silent ones — adr-anchors, cli-build-prerequisite, console-spec-probes,
eslint-fatal-guard, eslint-stack-headroom, i18n-bundle-surface, regen-artifacts — are pure
library modules: constants and pure functions, no CLI, no top-level statement that runs. The static
rule and the behavioural probe agree on all 29 files that could be loaded: 7 silent, 22 not, no
disagreement in either direction.
That agreement is why this is a rule and not an allowlist, and it
is the specific trap the card named ("39 ad-hoc allowlist entries, the drift one level up") being
avoided rather than argued away.

The harm, reproduced

$ node probe.mjs scripts/check-nul-bytes.mjs # probe prints ALIVE after the import
check-nul-bytes: OK (scanned 6211 text file(s) …)
IMPORTER_EXIT=0 # ← no ALIVE line

The importer never reached its own next line, and the exit code is 0. Five of the eight deaths
are exit 0 (check-nul-bytes, check-changeset-no-major, check-test-completeness,
pm/check-governed-prose, check-empty-changeset) — the same silent-success shape the spelling half
of this gate exists for, arriving through a different door. check-engine-split-ratio exits 2,
which is #9757 still live on main in a second file.

The card's population is keyed on the wrong property — the real number is 35

Guard presence is not the property that matters; what runs is. Keyed on that, the rule finds
35 files, and the membership is not a subset of the 39:

  • −11 files the card would have counted as unguarded are guarded through a const
    (const isMain = isEntrypoint(import.meta.url); … if (isMain) { … }), including the inverted
    idiom if (!invokedDirectly) { /* imported — do nothing */ } else if (…). The rule accepts all
    three spellings; an earlier cut that only looked for the call inside the if called every one of
    them a violation.
  • +3 files the card counted as already fixed are leaking, and both measurable ones leak
    conditionally on the importer's argv, so no clean-argv probe can see them:
$ TARGET=scripts/qa/qa-rollup.mjs node probe.mjs --self-test
✓ qa-rollup --self-test: 60 assertions over 7 real-corpus title shapes ← ran inside the importer
$ TARGET=scripts/objectui-range.mjs node probe.mjs --help
objectui-range — summarize the frontend (objectui) delta bundled between two …

Both are silent with a clean argv. This is exactly the shape pm/check-governed-prose.mjs already
hand-works-around, by filtering --self-test out of process.argv before importing a sibling and
putting it back afterwards. The third, scripts/ts-parse.mjs:462, registers a process.on('exit')
census hook when an env var is set. A behavioural gate — the card's option C — would have missed
all three.

Gate vs sweep

Gate. The 35 go into a ⛔ SHRINK-ONLYKNOWN_IMPORT_UNSAFE list, and the difference between a
debt list and an exception list is why it is safe to have one: every entry has the same one-line
remedy and none of them records a judgement anyone has to re-make. There is no supported route in
the other direction — a file the rule newly reaches is a failure with one remedy, never a line in
the list — which satisfies #8435 by refusing the expanding remedy (route b) rather than marking
it, so no ⛔ MAINTAINER-ONLY token and no CONTROL entry in
check-ratchet-remedy-authority.mjs are owed. That gate is green and still classifies this file as
handing out no ratchet-expanding remedy.

An entry whose file has been fixed fails as STALE and names itself, which is what stops the list
rotting into the allowlist the card warned about. Demonstrated end to end on check-nul-bytes.mjs
and then reverted:

1. guard added → ❌ 1 stale KNOWN_IMPORT_UNSAFE entry: scripts/check-nul-bytes.mjs
2. ledger line gone → ✓ … 47 of them inert on import (34 known-unsafe)
3. import re-probed → ALIVE — importer survived; exports: classify, findControlBytes, scan, …
4. CLI re-run → check-nul-bytes: OK (6211 files) · --self-test: 75 assertions

The burn-down is deliberately not in this PR: the claim declares
scripts/check-entry-guard.mjs, and 35 gate scripts is the file surface other seats are working in.
It is filed separately. Note the remedy is a 4-line insert with zero re-indentation using the
inverted idiom five files here already use, which is what makes it mechanical later.

Validation

  • pnpm check:entry-guard green, both modes:
    • ✓ check-entry-guard self-test: 47 cases pass — … (was 26; +21, all on the new rule)
    • ✓ check:entry-guard: 123 scripts/ file(s) — … 81 export bindings, 46 of them inert on import (35 known-unsafe, ⛔ SHRINK-ONLY)
  • The verdict line now carries the positive counts. This is a negative gate — it prints files
    scanned — so "the count went up" is not evidence a reader can use; recognition is asserted
    directly in the self-test on both sides instead.
  • Unmodified tree passes; no legitimate non-exporter or inert file is flagged (the 42 non-exporters
    and the 7 pure modules are all accepted, each pinned by a self-test case).

Ablations — every mutation confirmed on disk by anchor count, then restored

ablationpredictedobserved
guard exemption removedmany fresh39 fresh (= the guarded-clean exporters), 5 self-test cases red
guard-alias support removed11 fresh11 fresh, 3 self-test cases red
exportsBindings always true42 fresh42 fresh (= exactly the non-exporting CLIs)
detector blinded (runsOnImport → false)all 35 read STALE35 stale — the gate cannot go green by going blind
one ledger line deleted1 fresh1 fresh
a bogus ledger line1 stale1 stale, named

Two of these were void on the first attempt and are reported as re-runs, not as first results:
a \n passed literally into the source (syntax error, red for the wrong reason), and an
empty-string grep -F confirmation that matches every line.

The exportsBindings ablation reddened 0 on its first run, and that shortfall was the real
finding
: the export test was written twice — once in the detector, once in the census — so
blinding one copy left the gate green. Collapsed to one definition in 4a83b1a3db, after which the
ablation reddens 42. The duplicate is the shape of a rule enforced in one path and not the other.

Other gates run at 4a83b1a3db

node scripts/pm/dispatch-gates.mjs (no paths) derives check:entry-guard and
check:cross-package-test-inputs. Its silence about the rest is not a clearance, so the families
implicated by hand were run too:

gateverdict
check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared…
check:ratchet-remedy-authorityOK … 109 scripts swept; 7 mark …, 3 turn it down outright, 99 hand out no ratchet-expanding remedy
check:parse-guardexit 0
check:nul-bytescheck-nul-bytes: OK (scanned 6211 text file(s) …)

check:single-claim-paths exits 2 here for want of PR_NUMBER/GITHUB_TOKEN; it is a CI-context
gate, not a result about this change.

scripts/**-only, publishes nothing → skip-changeset.

Generated by Claude Code


Generated by Claude Code

@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT, arming. ⭐ The card's headline number is keyed on the wrong property, and you found it by measuring what actually runs.

Head 4a83b1a3db, 28/28 checks green, 0 failing, 0 running (latest run per check name). Reviewed against the tree and the PR, not the report.

⭐ The correction that reframes the card

The card counts 39 files — keyed on guard presence. You keyed it on what actually runs the population and got 35, and the membership is not a subset:

  • 11 files are guarded through a const alias (const isMain = isEntrypoint(import.meta.url); if (isMain) …), including the inverted if (!invokedDirectly) {} else if idiom. Your first cut called all 11 violations — and you caught that rather than shipping it.
  • 3 files the card counts as ALREADY FIXED are leaking: qa/qa-rollup.mjs runs its self-test inside any importer whose argv carries --self-test; objectui-range.mjs prints help for --help; ts-parse.mjs:462 registers a process.on('exit') hook under an env var.

I verified the third directly on origin/main: if (process.env.OS_TOOLING_PARSE_CENSUS) { process.on('exit', () => { — a top-level, env-conditional hook outside any guard, in a module this lane landed tonight.

And the sting: the card's own option C (import each candidate and assert silence) would have missed all three, because both measurable ones are silent with a clean argv. A behavioural probe is not a direct measurement of the property; it measures the property under one argv.

⭐ Gate, not sweep — and the measurement is what decides it

My brief said: price the gate-level answer first, and measure the exceptions before proposing the rule, because a handful of legitimate non-guardable files turns a blanket rule into 39 allowlist entries — the drift one level up. You did exactly that, by importing every exporting scripts/ file in a child process with a trailing ALIVE marker:

populationresult
42 guarded (33 loadable)all silent + alive — 0 dead, 0 noisy
39 unguarded8 killed the importer — FIVE of them with exit 0 · 14 ran the tool loudly · 7 silent

Those 7 silent ones are pure library modules — the legitimate exceptions. ⭐ And they cost zero allowlist entries, because the rule you landed is "a file that exports must have every top-level statement that runs behind the guard", which structurally never reaches a module of declarations. That is the difference between a rule and a rule-plus-exceptions, and it is why this is a gate rather than a sweep.

Static rule and behavioural probe agree on all 29 loadable files, no disagreement either way — that agreement is the licence to make it a rule.

The ledger is designed against the drift I warned about

35 violators go into an inline shrink-onlyKNOWN_IMPORT_UNSAFE list with the expanding remedy refused outright (#8435 route b — so no MAINTAINER-ONLY token and no CONTROL entry is owed, and check-ratchet-remedy-authority is green and still classes this file as handing out no ratchet-expanding remedy). ⭐ A fixed file fails as STALE and names itself, so the list cannot rot into an allowlist — and you demonstrated that burn-down loop end to end on check-nul-bytes.mjs and reverted it clean.

⭐ The shortfall that was a real finding

Ablation (3) reddened zero on its first run. You treated the shortfall as a signal, as the brief asked, and the cause was your own code:

the export test was written twice, once in the detector and once in the census, so blinding one copy left the gate green.

"A rule enforced in one path and not the other is exactly the shape this card is about, one level down." You collapsed it to one exportsBindings definition, after which the ablation reddens 42. That is the card's own defect found inside the card's own fix, and it would have shipped as a green.

Positive control (4) is the one a negative gate most needs: blinding runsOnImport to false makes all 35 read STALE — "the gate cannot go green by going blind."

⚠️ Two void ablations, reported as re-runs — the fourth incident tonight

One passed a literal \n into the source (a syntax error — red for the wrong reason); the other used grep -F with an empty pattern, which matches every line and printed 707. ⭐ "I re-ran it cleanly rather than accept a self-contradicting instrument." Both are new mechanisms with the same signature as the earlier three: an instrument that returns something while measuring nothing.

Three card options falsified

  • A (exports AND any top-level call) would flag essentially the whole tree — const HERE = resolve(fileURLToPath(import.meta.url), '..') is a top-level call in nearly every file here, including check-entry-guard.mjs itself.
  • B (exports AND reads argv) has a false-positive side the card omits — eslint-stack-headroom.mjs exports, reads argv, and is inert, so B demands a pointless empty guard on a library module.
  • C — blind to argv/env-conditional leaks, as above.

Harm reproduced

Importing check-nul-bytes.mjs for its exports printed its full 6211-file verdict and ALIVE never printed, with IMPORTER_EXIT=0 — a silent, exit-0 death of the importer mid-import. check-engine-split-ratio.mjs exits 2 into the importer (#9757 still live in a second file). Controls: two library modules printed ALIVE with zero output, and no import mutated the tree.

Declared narrowings, both correct

  • No os-verify-lock VERDICT line, because no heavy build or test ran — "inventing one would be worse than saying so."
  • node_modules absent, so 10 of 39 and 9 of 42 could not be loaded by the probe — stated in the PR table rather than papered over.
  • The GitHub search API is unavailable to these sessions and the MCP search_issues tool was rate-limited, so the duplicate search was done by paging repos/.../issues. Worth knowing; I am carrying it into the brief template.

#10667 filed and triaged — the burn-down of the 35, correctly excluded here (the claim declares check-entry-guard.mjs, and 35 gate scripts is surface other seats are working in). The note about pm/check-governed-prose.mjs mutating process.argv at module scope to filter --self-test before importing a sibling belongs there too: that workaround is a scar from this exact class.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-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-zhuang@claude