Skip to content

refactor(tooling): route all 29 scripts/ entry guards through isEntrypoint - #6145

Merged
yinlianghui-tw merged 3 commits into
mainfrom
claude/issue-6092-entry-guard-sweep
Aug 24, 2026
Merged

refactor(tooling): route all 29 scripts/ entry guards through isEntrypoint#6145
yinlianghui-tw merged 3 commits into
mainfrom
claude/issue-6092-entry-guard-sweep

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Part of #6092

PR 2 of 2. PR #6133 landed the gate and the shrink-only baseline it measured; this converts every one of the 29 sites that baseline named and empties it. The card's other open item — check-lucide-icon-record-names.mjs's unguarded top-level loops — is reported, not restructured, on the dispatch order's own STOP condition. Details below.

Everything here was measured at final commit 06ad4dff0, working tree clean apart from an untracked throwaway harness directory.

Re-measurement at claim

main moved by six merges since PR 1's figures (#6128, #6130, #6133, #6134, #6136, #6137). Branched from bfdb9f906.

figurePR 1 @ 133e2ea1ethis branch @ bfdb9f906delta
hand-typed guard files2929none
process.argv[1] occurrences5454none
distinct spellings99none
isEntrypoint importers35+2 — check-entry-guard.mjs and js-comment-mask.mjs, both landed by PR 1 itself

The gate's own verdict line at the branch point reproduced PR 1's numbers exactly:

✓ check:entry-guard: 41 scripts/ file(s) — no entry guard outside the baseline; 29 file(s) still
hand-type one (54 occurrence(s), ⛔ SHRINK-ONLY, objectui#6092); 36 export bindings, 35 of them
inert on import (1 known-unsafe, ⛔ SHRINK-ONLY).

and at the end of this PR:

✓ check:entry-guard: 41 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still
hand-type one (0 occurrence(s), ⛔ SHRINK-ONLY, objectui#6092); 36 export bindings, 35 of them
inert on import (1 known-unsafe, ⛔ SHRINK-ONLY).

29 files → 0. 54 occurrences → 0.KNOWN_HAND_TYPED_GUARDS is empty; --list's hand-typed section prints nothing. Only three scripts/ files still contain the string process.argv[1] at all: the gate (skipped — it quotes the idioms it bans), invoked-as.mjs (the one module allowed to read it), and js-comment-mask.mjs, whose 8 occurrences are all inside literals in its own corpus.

Note on this body: angle-bracket placeholders are written out in words throughout. GitHub's body sanitizer silently ate three of them from the first revision of this description — node scripts/ followed by a bracketed FILE, and two mentions of the node command given a bracketed DIRECTORY — turning measured sentences into nonsense. Same class of hazard as the guards this PR converts: it fails quietly and reads fine.

Per-lane both-leg verification, and the harness's positive control

⛔ Not a bulk sed. A two-leg harness ran every one of the 29 before and after, each with its own argv chosen so the run crosses the guard without doing anything destructive (--check for the two writers, --help for shadcn-sync.js, --self-test for check-cross-repo-closer-outcome.mjs, --no-build for check-published-dist-tooling.mjs, and so on).

  • CLI leg — the script invoked as a command. Dispatched iff it did not do the one thing an inert guard does: exit 0 having printed nothing.
  • Import leg — the same file imported. Inert iff the graph loaded, control returned, and the module printed nothing of its own.

Result: 29/29 dispatched on the CLI leg and inert on the import leg. 27 of 29 produced byte-identical CLI output before and after (same exit code, same sha256 of stdout+stderr).

The two that differ, both explained and neither caused by the conversion:

  • check-changeset-presence.mjs, 296 → 297 bytes: it reports on the branch's own diff. "0 file(s) changed""30 file(s) changed". A lane whose output was identical here would mean it had stopped reading the tree.
  • check-i18n-dead-keys.mjs, 49115 → 28719 bytes: its stdout is truncated nondeterministically when it is a pipe (the script ends in process.exit, so a large piped write is not flushed). Proved by running the same post-conversion tree three ways: 49115 bytes through a file redirect, 28719 and 32813 bytes through the harness's pipe. Byte count is not a stable signature for this lane in either direction; its stable signature — exit code, dispatch, and first lines — is unchanged.

⛔ The harness reports red, and here is the proof

A harness that has never reported red is not a reading. Two deliberate mis-conversions of check-skills-paths.mjs, each confirmed on disk by a before/after occurrence count, each restored by trap … EXIT INT TERM using git checkout HEAD -- (the fix was committed first):

mutationon-disk confirmationCLI legimport legharness
guard inverted (!isEntrypoint(…))canonical spelling 1 → 0, inverted present = 1exit=0, 0 bytes — the silent-success signature itselfNOT inert, 97 bytesFAIL
guard deleted (if (true))guarded dispatch 1 → 0, unguarded present = 1exit=0, 97 bytes (dispatches)NOT inert, 97 bytesFAIL

Both directions caught, and the inverted case reproduces the card's whole thesis: a converted-then-broken gate goes silently inert while its own work reappears inside anyone who imports it.

The conversion is a real behaviour change, in the good direction

Measured on this branch through a real symlink, on check-skills-paths.mjs — the gate #6078 used for its demonstration. Restore leg git checkout HEAD --, comparison leg git checkout bfdb9f906 --:

spelling on diskdirectthrough a symlink
converted (isEntrypoint)exit=0, 96 bytes, prints its verdictexit=0, 96 bytes, same verdict
pre-conversion (path.resolve(argv[1]) === fileURLToPath(import.meta.url))exit=0, 96 bytesexit=0, **0 bytes** — silently inert

Nothing relies on the inert behaviour. How that was checked, rather than assumed:

  1. git ls-files -s | awk '$1=="120000"' — the tree contains no tracked symlinks.
  2. Every invocation of every converted script, across .github/workflows/** and package.json, is the literal command node followed by the plain relative path of the file, run from the repo root — no symlink, no directory argument. The full list was enumerated, not sampled.
  3. scripts/ contains no index.mjs/index.js, so the case where node is handed a DIRECTORY and resolves its index — which the predicate additionally handles — cannot arise here either.
  4. The 1894-test scripts/__tests__ suite — which spawns these scripts from 56 sites — is green.

So the only situation in which old and new answer differently is one no current caller can reach; the change is latent-hazard removal, exactly as the card framed it.

The pre-install path, verified rather than reasoned about

PR 1 wired the gate into lint.ymlbefore pnpm install. A parse of every workflow found 8 steps that invoke a converted script before any pnpm install in the same job: changeset-guard.yml, changeset-presence.yml, ci.yml (×2), control-bytes.yml, doc-component-types.yml, docs-links.yml, skills-paths.yml.

All 29 converted files sit at scripts/ depth 0, so ./invoked-as.mjs is right for every one — but that was checked, not assumed. Each of the 8 was loaded under a resolver hook that throws on any specifier that would need node_modules: all 8 exit 0.

The hook is not vacuous, and the depth check is not vacuous:

  • control A — the same hook on check-doc-snippet-types.mjs (a post-install lane): Error: NEEDS_NODE_MODULES: typescript, exit 1.
  • control B — a probe importing '../invoked-as.mjs' from scripts/: ERR_MODULE_NOT_FOUND. A wrong relative depth is a hard load failure, never a silent one.

One test had to be strengthened, and it is stronger, not looser

scripts/__tests__/check-doc-component-types.test.ts's needs no install case asserted that every import in that gate starts with node:. The conversion adds ./invoked-as.mjs — install-free, but not that spelling — so the test failed. This is the trap working.

Loosening the predicate to "builtin or relative" would have been the wrong repair: it would let a relative import that does pull a package in later land unnoticed. Instead the assertion now walks the whole static import graph and requires every leaf to be a builtin, which keeps the original claim ("this needs no node_modules") literally true and extends it to every module the gate reaches.

Positive control, mutation confirmed on disk and restored by trap: adding import ts from 'typescript' to scripts/invoked-as.mjsone hop away from the gate, invisible to the old single-file assertion — reddens it:

AssertionError: the gate's import graph reaches a package, so it needs an install:
scripts/invoked-as.mjs -> typescript: expected [ Array(1) ] to deeply equal []

⚠️check-lucide-icon-record-names.mjs — STOP, per the dispatch order

The order ruled: "⛔ If those loops feed module-level exports that importers actually read, STOP and report rather than restructuring the module."They do, and the measurement is worse than the condition anticipated.

The two top-level for loops (now :243/:245, one line lower after the added import) build keyByComponent and kebabByKey. Those are module-private, but they are read by the exported liveSpellingFor and describeName, which importers really call — scripts/__tests__/check-lucide-icon-record-names.test.ts calls both.

Ablation on this branch, mutation confirmed on disk (top-level loop lines 1 → 0, indented-behind-guard 1), restored by trap:

  • rule 2 goes green and reports the KNOWN_IMPORT_UNSAFE entry as STALE — the baseline is satisfied;
  • the importing suite fails 5 of 25;
  • and the failure is not merely a red test. describeName('BarChart3') stops saying write `chart-column` and says "no live key names the same glyph" instead — a wrong diagnosis for a real violation, printed by a gate that still exits 1. That is a worse outcome than the unguarded loops, not a better one.

So the loops are left alone, the KNOWN_IMPORT_UNSAFE entry stays at one, and the gate's comment now records the measurement instead of claiming the entry has "one remedy" (it does not — it needs a judgement, which is precisely what that comment says a debt line must not be). A lazy build of the two maps inside liveSpellingFor would satisfy both sides and is noted there as the shape a follow-up card should consider. Its guard spelling is converted with the other 28; only the import-safety restructuring is deferred.

shadcn-sync.js — a simplification, kept visibly distinct

It hand-typed the correct two-leg shape (realpathSync(resolved) === __filename) and the gate never flagged it. Converting it deletes an 11-line invokedAsCli() and the realpathSync import it existed for. The call site now carries a comment saying, in the file itself, that this one was a simplification rather than a fix — and that the shared predicate additionally handles the node-given-a-directory case the hand-typed copy never had.

The gate's header, rewritten to the swept state

PR 1's own lesson — a ported header describing a tree that no longer exists is the defect #6078 recorded — applies to this PR too. Every claim in check-entry-guard.mjs's header was re-checked against the post-sweep tree, and four had gone false:

  • "This repository has not been swept" / "have drifted into NINE distinct spellings" — now past tense, with the baseline described as empty and the reason it stays.
  • The three baseline consequences — the STALE and count-raising legs are unreachable while the map is empty; they stay in reconcileGuards (and pinned by the self-test) because that is the state a re-added line would have to pass through.
  • The shadcn-sync.js paragraph — its distinction now lives at the call site.
  • The masking example was stale in a way worth naming: the header cited shadcn-sync.js:1046 as the file that "really does write one in prose" — and that docblock is exactly what this PR deletes. It now cites the measured survivor, js-comment-mask.mjs's 8 in-literal occurrences, which would read as a 30th hand-typed guard if the masking ever regressed.

One self-test case changed shape: the loop over CORRECT_SHAPE_BUT_HAND_TYPED was data-driven and becomes vacuous once that set is empty, so it is replaced by an explicit assertion that an empty baseline still calls a real guard FRESH. A test reading the map's size would have passed on an empty map that had also stopped being consulted; this one asserts the reconciliation. Count is unchanged at 63.

Verification, each quoting its own verdict line

All at final commit 06ad4dff0. Exit codes captured before any pipe.

node scripts/check-entry-guard.mjs --self-test exit=0
✓ check-entry-guard self-test: 63 cases pass — all 9 spellings measured in this tree rejected, …
node scripts/check-entry-guard.mjs exit=0
✓ check:entry-guard: 41 scripts/ file(s) … 0 file(s) still hand-type one (0 occurrence(s) …
node scripts/js-comment-mask.mjs --self-test exit=0
✓ js-comment-mask self-test: 35 cases pass (23 mask/strip corpus, 12 interpolation view).
node scripts/invoked-as.mjs --self-test exit=0
✓ invoked-as self-test: 12 cases pass (real symlink, different-name symlink, percent-encoding …
node scripts/check-control-bytes.mjs exit=0
✅ check-control-bytes: OK (scanned 5088 tracked text file(s); skipped 85 binary).
node scripts/check-lint-coverage.mjs exit=0
✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
node scripts/check-changeset-presence.mjs exit=0
✅ No source of a released package changed in this range, so no changeset is owed.
node scripts/check-changeset-no-major.mjs exit=0
✅ No changeset declares a `major` bump.
node scripts/check-changeset-fixed.mjs exit=0
✅ privatePackages declared: version=true, tag=false.
node scripts/check-skills-paths.mjs exit=0
✅ check-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).
node scripts/check-doc-links.mjs exit=0
Links are valid across 15 scan roots.

Plus: the remaining 18 converted gates all ran as the harness's CLI leg above, with their verdicts captured before and after.

npx vitest run scripts/__tests__/ --maxWorkers=2 exit=0
Test Files 69 passed (69) · Tests 1894 passed (1894) # run from the repo ROOT, never package-scoped
pnpm type-check:scripts exit=0 # tsc -p tsconfig.scripts.json, no diagnostics
pnpm lint:root exit=0 # the FULL root scan covering scripts/, 6.8s
✖ 28 problems (0 errors, 28 warnings) # all pre-existing — see below

No lint narrowing was needed.pnpm lint:root (eslint . over everything outside packages/examples/apps/docs, which is where this whole diff lives) ran the full population in under seven seconds. Its 28 warnings are all pre-existing: eslint --format json over the 31 changed lintable files reports exactly 31 file objects with errorCount=0 and warningCount=0 each, so none of the 28 is in this diff. pnpm lint (turbo run lint) is per-package and this diff touches no package source.

Control bytes:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over every changed file returns no matches, and check-control-bytes.mjs is green above.

Not done, and not owed by this seat: CI convergence. The report lands at draft-PR time per the dispatch contract; the PM verifies the real gate jobs.


Generated by Claude Code

…point
The second half of objectui#6092. PR #6133 landed the gate and the
shrink-only baseline it measured; this converts every site the baseline
named and empties it.
Nine distinct hand-typed spellings across 28 .mjs files became one:
import { isEntrypoint } from './invoked-as.mjs';
if (isEntrypoint(import.meta.url)) { ... }
Twenty-eight of those spellings were WRONG, in the direction nothing in
CI can see: node resolves symlinks for the module graph but leaves
process.argv[1] as the caller typed it, so a gate reached through a
symlink compared two different paths, answered false, and did nothing --
exit 0, no output, which a wrapper holding result.status reads as a pass.
One spelling (check-node-esm-load.mjs) went inert with no symlink at all,
percent-encoding apart from argv[1] in any directory whose name needs
encoding.
scripts/shadcn-sync.js is the twenty-ninth and is different in kind: its
invokedAsCli() already compared through realpathSync, so it was already
correct. Its conversion is a SIMPLIFICATION, not a fix, and the call site
now says so.
KNOWN_HAND_TYPED_GUARDS is empty. It stays, because the reconciliation it
feeds is the live rule: with no lines left, any scripts/ file that
hand-types a guard fails as FRESH and names itself. The gate's header is
rewritten to the swept state rather than left describing a tree that no
longer exists -- the ported-prose defect objectui#6078 recorded.
check-doc-component-types.test.ts pinned "needs no install" by requiring
every import in that gate to start with `node:`. A relative import of a
builtins-only local module keeps that claim true but not that spelling,
so the assertion now walks the whole static import graph and requires
every leaf to be a builtin. That is the stronger form of the same claim,
not a loosened one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
… gate
The KNOWN_IMPORT_UNSAFE comment said the entry had "one remedy". It does
not: moving check-lucide-icon-record-names.mjs's two top-level loops
behind the guard turns rule 2 green and breaks the module's importers.
Measured, not reasoned -- 5 of 25 cases in the importing suite fail, and
describeName() starts printing a WRONG diagnosis for a real violation
rather than merely failing.
So the line stays and the comment now says why, including the lazy-build
shape a future card should consider.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui-tw@claude