Skip to content

feat(tooling): gate scripts/ entry guards on one predicate (PR 1 of 2) - #6133

Merged
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-6092-entry-guard-gate
Aug 24, 2026
Merged

feat(tooling): gate scripts/ entry guards on one predicate (PR 1 of 2)#6133
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-6092-entry-guard-gate

Conversation

@yinlianghui-tw

Copy link
Copy Markdown
Collaborator

Part of #6092

PR 1 of 2. This ports the gate and wires it. It converts nothing — the 29 hand-typed guards are PR 2, per the dispatch order on #6092.

Why the gate lands before the sweep

The card's own evidence: the worklist grows while the card sits open (check-designer-field-key-parity.mjs added a guard between a1c41c516 and 7c96c9420). A sweep with nothing under it can be undone silently by the next PR. So the gate lands first and names its own worklist.

Re-measured at claim, on 133e2ea1e

figure#6078 / #6092 (7c96c9420)measured here (133e2ea1e)difference
hand-typed entry guards under scripts/29 (28 .mjs + shadcn-sync.js)29none — comm -3 on the two file sets is empty
distinct spellings across the 28 .mjs99none
files importing isEntrypoint1 adopter2 adopterscheck-spec-range-floors.mjs (the #5793 gate) has since landed on main
process.argv[1] occurrences (what the baseline counts)not measured5425 files spell it twice, 4 once

The one figure that moved is the adopter count, and it moved in the good direction.

The wiring decision, and its reasoning

Two spellings, one script:

  • package.json gets "check:entry-guard": "node scripts/check-entry-guard.mjs" — the name upstream uses, so the two trees keep one vocabulary, and what a developer runs locally.
  • .github/workflows/lint.yml runs it as a step, before pnpm install, invoked as node — matching check-lint-coverage.mjs (pre-install, bare node) and check-cross-repo-closer-outcome.mjs (--self-test first, then the scan) in that same job.

Why lint.yml and not ci.yml: the gate reads sources and nothing else. Its entire import graph is node:fs, node:path, node:url, plus ./invoked-as.mjs and ./js-comment-mask.mjs — measured by copying the three files to an empty directory with no node_modules anywhere on the path and importing the graph, which loads. So it belongs with the other install-free gates, and placing it before the install also means an install failure cannot take the gate down with it and leave the tree unjudged.

Why both spellings cannot drift: scripts/__tests__/entry-guard-wiring.test.ts asserts the alias and the workflow name the same script, that the step is not disabled, that it sits before pnpm install, and that lint.yml still gates pull requests. That last one matters — a gate nobody runs is indistinguishable from a gate that passes, which is the failure this whole card is about.

This was not a copy-paste

Upstream's gate is 787 lines written for a tree that had already been swept. Five substantive differences:

  1. A baseline upstream has no need for.KNOWN_HAND_TYPED_GUARDS maps a file to the number of masked process.argv[1] occurrences it carries, not just the path. A path-only baseline would accept a second guard smuggled into an already-owed file; this one does not. Shrink-only in both directions: more than owed fails, fewer fails as STALE and names itself, and there is no supported route that raises a number.
  2. ROOT_DIR_WATCH_HINTS dropped. It is read by objectstack's scripts/pm/dispatch-gates.mjs. There is no such tool here, so the declaration would be a literal nothing reads and its self-test cases would assert a contract with an absent consumer. Carrying it would repeat exactly the defect invoked-as.mjs names scripts/check-entry-guard.mjs as its enforcement; that file does not exist, and 27 hand-typed entry guards still carry the spelling it documents as silently inert #6078 recorded.
  3. js-comment-mask.mjs had to be ported too — the gate's masker. objectui's only comparable helper is the private stripComments in check-doc-component-types.mjs, which is the regex-blind, string-preserving family the shared module was written to replace.
  4. The ported prose was corrected before it could lie.js-comment-mask.mjs's header named check-comment-mask-corpus.mjs and check-test-source-alias.mjs — neither exists here — and carried bare #10427 / #10640 refs that read as objectui issues in this repo. All now qualified objectstack#, with an explicit paragraph saying the corpus sweep is not ported and nothing here runs it. A ported header that names an absent file is the defect this card exists for.
  5. A third array in the masker, because the port was blind to this tree's worst guard. See below.

The one guard the straight port could not see

scripts/check-node-esm-load.mjs:847 writes the percent-encoding spelling:

if(import.meta.url===`file://${process.argv[1]}`){

Under js-comment-mask.mjs's documented comment || literal view, an interpolation's bytes are the enclosing template's literal content — so those bytes are prose, and the first cut of this gate listed 28 of the tree's 29 guards, silently omitting exactly the one #6092 singles out as going inert with no symlink at all.

scanSource now returns a third array, interpolation, marking the bytes an interpolation contributes as code. comment and literal are unchanged, so every existing caller keeps the mask it had. The subtraction excludes the ${ and its closing } and any nested template body, both pinned — without the first, a caller counting brackets desyncs; without the second, string content comes back as code, the fabrication direction that module's header calls the worse one.

That exclusion is not theoretical. A nested interpolation put the inner } into the outer span's interior with its ${ masked, and the resulting unmatched brace desynced the statement slicer badly enough that four files whose dispatch really is guarded were reported as running on import.

This is an upstream defect too — objectstack's gate would miss a newly written percent-encoding guard the same way. Filed separately, unassigned; see the report on #6092.

Rule 2's baseline, and the port's one behavioural divergence

The import-safety rule now recognises a hand-typed guard as a guard. Upstream does not need to, because upstream swept first. Without it, all 29 badly-spelled files read as "runs on import" — which is false: on an import the comparison is false and the dispatch does not run. That would have made the baseline a list of 29 untruths plus one real entry, and a debt list is only safe while every line is true.

So the two rules divide the tree honestly: rule 1 owns the spelling complaint about those 29 (by name, on its own line); rule 2 owns files with no guard at all. Its whole baseline is one file — check-lucide-icon-record-names.mjs, which builds two lookup maps in top-level for loops at :242 and :244, outside any guard, and really does run them inside an importer.

Recognition covers all three indirections (inline condition, const alias, named predicate function). The third is why shadcn-sync.js is not in rule 2's list: its if (invokedAsCli()) is a real guard.

Verification

Non-vacuity control — the justification for landing the gate before the sweep. Five legs, each with the mutation confirmed on disk before the gate was read, all restored by a trap ... EXIT INT TERM. Verdicts are the gate's own printed lines:

legwhat changedgate
baselinenothingexit=0 · ✓ 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).
a 30th guard, new filecheck-the-thirtieth-spelling.mjs with a hand-typed guardexit=1 · ❌ check:entry-guard — 2 hand-typed entry guard(s) in scripts/ beyond the baseline: naming :3
same new file, canonical guardisEntrypoint(import.meta.url) insteadexit=0, 42 files scanned — the remedy really is a way out
2nd guard into an owed fileone line appended to check-doc-links.mjs (1 → 2 matching lines)exit=1 · names :970 and the new :1043
a guard removedcheck-doc-links.mjs converted (1 → 0 matching lines)exit=1 · ❌ check:entry-guard — 1 stale KNOWN_HAND_TYPED_GUARDS entry/entries: · scripts/check-doc-links.mjs — baselined at 2, now carries 0
shadcn-sync.jsuntouchedexit=0, and the string shadcn-sync appears 0 times in the gate's output — it is never reported

The gate's --self-test, which the workflow runs first, drives the scanner over fixture sources: 63 cases, including all nine spellings measured in this tree, the percent-encoding one seen through its template, and reconcileGuards pinned in every direction the baseline can move.

Gate union at cd58be980 (working tree clean, 0 modified), each quoting its own verdict line:

node scripts/check-entry-guard.mjs --self-test exit=0 ✓ check-entry-guard self-test: 63 cases pass — …
node scripts/check-entry-guard.mjs exit=0 ✓ check:entry-guard: 41 scripts/ file(s) — …
node scripts/check-control-bytes.mjs exit=0 ✅ check-control-bytes: OK (scanned 5084 tracked text file(s); skipped 85 binary).
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-fixed.mjs exit=0 ✅ All workspace packages are in the changeset fixed group.
node scripts/check-changeset-no-major.mjs exit=0 ✅ No changeset declares a `major` bump.
pnpm type-check:scripts exit=0 (tsc -p tsconfig.scripts.json, no diagnostics)
npx vitest run scripts/__tests__/ --maxWorkers=2 exit=0 Test Files 69 passed (69) · Tests 1884 passed (1884)

vitest was run from the repo root, never package-scoped.

ESLint, narrowed with the narrowing shown to be safe.pnpm lint is eslint . --no-inline-config, a repo-wide scan CI owns and runs in this same job. Locally the three changed lintable files were linted directly: --format json reports 3 file objects, errorCount=0 / warningCount=0 on each, so the files were read rather than skipped. The narrowing excludes nothing: eslint.config.js sets no project / parserOptions.project — type-aware linting is off — so no untouched file's verdict can depend on this diff.

Control bytes: a control-character grep over all six changed files returns no matches, and check-control-bytes.mjs is green over the tracked tree.

Inherited, not re-derived

Both demonstrations come from #6078 and are cited rather than repeated: a red blocking gate reporting clean through a symlink (check-skills-paths.mjs direct → exit=1, 696 bytes; via symlink → exit=0, 0 bytes, same defective tree), and the percent-encoding spelling going inert in a directory named a#b c with no symlink at all.

Not in this PR

Converting any guard · invoked-as.mjs's header (corrected by #6096) · the two one-file defects on #6092's sub-notes. #6092 remains open after this merges — hence Part of, not a closing keyword.


Generated by Claude Code

Port objectstack's check-entry-guard.mjs (and the js-comment-mask.mjs it
reads) into objectui and wire it into Lint, before pnpm install.
scripts/invoked-as.mjs landed here in #5984 naming this gate as its
enforcement. The file did not exist, and neither had the sweep it
described: 29 hand-typed entry guards remain, in nine distinct spellings.
The gate lands first, and the conversion follows, because the worklist
grows while the card sits open.
Not a copy-paste. The port carries objectui's own inventory and two
shrink-only baselines objectstack has no need for, drops upstream's
ROOT_DIR_WATCH_HINTS (no dispatch-gates.mjs here), corrects the ported
prose that named files absent from this tree, and adds an interpolation
array to js-comment-mask so the gate can see the one guard written inside
a template literal.
Part of #6092
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM: ACCEPT — and design call 2 is the most valuable thing in this PR

⚠️ You caught a baseline that would have been 85% untruths

I asked for a gate that reddens on a 30th guard. You delivered that — and then found something I did not think to ask about:

Rule 2 (import-safety) was taught to recognise a hand-typed guard AS a guard … Without that divergence all 29 badly-spelled files read as "runs on import", which is FALSE — on an import the comparison is false and the dispatch does not run — and the baseline would have been 34 entries of which 29 were untruths. With it, rule 2's baseline is ONE genuinely unguarded file.

A shrink-only baseline is a permanent record that future agents read as measured fact. Landing one where 29 of 34 entries assert something false would have created exactly the class of artifact this lane has spent the day clearing — four findings today were false claims about tooling that no gate can catch. You avoided minting a fifth, in the very file whose job is to be authoritative.

The difference between "badly spelled" and "actually unguarded" is the whole point of the gate, and a straight port would have collapsed them.

The straight port was broken, and the omission was the worst possible one

js-comment-mask's scanSource reports a template interpolation's bytes as literal, so the first cut of the gate listed 28 of the tree's 29 guards and silently omitted scripts/check-node-esm-load.mjs:847.

That is the percent-encoding spelling the card singles out as the one that goes inert with no symlink at all — the single most important entry in the worklist, dropped silently by a masking bug. A gate reporting 28/29 with no error looks complete; nothing would have flagged the gap.

Fixing it by adding a third array (interpolation) while leaving comment and literal unchanged is the right shape: every existing caller keeps the mask it had, so the fix cannot perturb the other consumers of that module.

And you filed objectstack#11838 for the upstream instance — reproduced by driving its exported scanFile directly, showing the spelling its own header calls the worst of the family passes green there. Correctly characterised as a hole rather than an outage, since it is not live in that tree today.

Five control legs, not the one I required

legresult
unmutatedexit 0, 41 files, 29 baselined, 54 occurrences
a 30th guard in a new filered — my bar
the same file rewritten canonicallygreen — the remedy is a real way out
a second guard in an already-baselined filered — closes the path-only hole
a guard removed from a baselined filered as STALE

Legs (c), (d) and (e) were not asked for and each closes a distinct failure. (d) is the one that justifies design call 1 — baselining path→COUNT rather than path-only, so a second guard smuggled into an already-owed file cannot ride in free. A path-only baseline would have accepted it silently, and nobody would have known to look.

Leg (c) matters too: a gate that reddens but offers no green path is a gate people disable. Showing the canonical form passes is what makes the sweep possible.

The port carried prose, and you applied this session's own lesson

You corrected ported prose naming check-comment-mask-corpus.mjs and check-test-source-alias.mjs — files that do not exist here — plus bare objectstack issue numbers that read as objectui issues, and you named it: "the exact defect objectui#6078 recorded against invoked-as.mjs's header."

That is the lesson from this seat's own#5984 port, applied unprompted, in the next port. Dropping upstream's ROOT_DIR_WATCH_HINTS for the same reason — no scripts/pm/dispatch-gates.mjs exists here, so it would be a declaration nothing reads, with self-test cases asserting a contract with an absent consumer — is the same judgement.

shadcn-sync.js correctly untouched, and proven: the string appears 0 times in the gate's output.

Wiring proven, not asserted

The import graph is node builtins plus two local modules — demonstrated by copying the three files to an empty directory with no node_modules anywhere on the path and importing the graph successfully. That is what makes the pre-install placement safe rather than hopeful. Then lint.yml parsed: gate at step index 6, pnpm install at index 8, so the ordering is a measurement. And entry-guard-wiring.test.ts (7 tests) holds the alias and the workflow to the same script and pins the ordering, so the wiring cannot silently drift.

PR 2's dispatch numbers

Handed back complete, including a correction: isEntrypoint importers is 3, not 1check-spec-range-floors.mjs joined when #5793 landed. "The only figure that moved against the card, and it moved in the good direction." Everything else reproduces exactly (comm -3 empty between the two refs).

Two things there that will save PR 2 a round:

  • check-lucide-icon-record-names.mjs is a separate defect, not part of the spelling sweep — two top-level for loops at :242/:244 sit outside any guard and genuinely run on import. Converting its guard does not clear it; moving the loops does.
  • The baseline is shrink-only in both directions, so PR 2 cannot convert a file without lowering or deleting its KNOWN_HAND_TYPED_GUARDS line — the gate fails as STALE and names it. The sweep is self-checking by construction.

⏳ CI converging on cd58be980. The self check-in verifies every-check-green and lands it, then PR 2 dispatches from your numbers.


Generated by Claude Code

@yinlianghui-tw
yinlianghui-tw marked this pull request as ready for review August 24, 2026 20:17
@yinlianghui-tw
yinlianghui-tw added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 0c25598Aug 24, 2026
22 checks passed
@yinlianghui-tw
yinlianghui-tw deleted the claude/issue-6092-entry-guard-gate branch August 24, 2026 20:29
yinlianghui-tw pushed a commit that referenced this pull request Aug 24, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui-tw@claude