Skip to content

fix(scripts): route every entry guard through one symlink-correct predicate, and close the class - #10275

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-10086-invoked-directly-entry-guard
Aug 20, 2026
Merged

fix(scripts): route every entry guard through one symlink-correct predicate, and close the class#10275
os-zhuang merged 3 commits into
mainfrom
claude/issue-10086-invoked-directly-entry-guard

Conversation

@claude

@claudeclaudeBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes#10086

The measurement, re-derived

The card estimated "~8 spellings". The census on origin/main @ 923c4247 is 11 distinct spellings across 33 files — re-derived, not taken from the card, and the roster differs too (the card listed ~20 files and named 1 basename-matcher; there are 3).

Every spelling was probed as a synthetic module invoked four ways. INERT = exit 0, no output:

#spellingsitesdirectsymlink (same name)symlink (diff name)path with #
S1resolve(argv[1] ?? '') === resolve(fileURLToPath(...))11RANINERTINERTRAN
S2same, no resolve on the right10RANINERTINERTRAN
S3import.meta.url.endsWith(argv[1].split('/').pop())3RANRANINERTRAN
S4import.meta.url === pathToFileURL(argv[1] ?? '').href2RANINERTINERTRAN
S5check-governed-merges.mjs's variant1RANINERTINERTRAN
S6invokedAs(...) — already fixed by #100841RANRANRANRAN
S7new URL(import.meta.url).pathname === argv[1]1RANINERTINERTINERT
S8resolve(argv[1] ?? '') === fileURLToPath(...)1RANINERTINERTRAN
S9import.meta.url === pathToFileURL(argv[1]).href1RANINERTINERTRAN
S10argv[1].endsWith('qa-rollup.mjs')1RANRANRANRAN
S11import.meta.url === new URL(`file://${argv[1]}`).href1RANINERTINERTINERT

Three of the card's premises moved, and each changes what the fix has to do:

  1. "Every one is inert through a symlink" is false. S3 (3 files) and S10 (1 file) are basename matchers: they survive a same-named symlink and fail the opposite way — they answer true for any entry sharing the basename, so they fire on import. A fix that only chased inertness would have left four files broken in the other direction.
  2. The percent-encoding claim is right but not for the reason given. S11 and S7 go inert on a path containing # — but a space is fine, because URL normalises that one. So the encoding failure is sporadic in exactly the way that resists attribution.
  3. qa-rollup.mjs was never symlink-inert at all — its defect is import-firing only.

Why this one mattered

scripts/pm/check-governed-merges.mjs is the audit half of the governed-surface regime, where human merge is the review record. Its --test mode is the pre-arm predicate a seat runs before arming auto-merge, and EXIT_TEST_NOT_GOVERNED is 0. Reproduced on origin/main:

$ node <symlink-to>/check-governed-merges.mjs --test AGENTS.md # ← no outputexit=0

The same invocation, run directly, exits 3 and prints ⛔ GOVERNED — a human merge is the review record for this PR. So through a symlink the register's "this PR is GOVERNED, no seat arms auto-merge" answer and its "NOT governed, ordinary queue landing applies" clearance are the same exit code. After this change:

$ node <symlink-to>/check-governed-merges.mjs --test AGENTS.mdgoverned-surface predicate: 1 of 1 path(s) hit the register (5 surfaces, repo-agnostic). ⛔ GOVERNED — a human merge is the review record for this PR (#9495 regime).exit=3

What this does

1. One predicate — scripts/invoked-as.mjs. Exports isEntrypoint(import.meta.url), which takes one argument and reads process.argv itself, so a call site has no comparison left to spell wrongly. invokedAs(entryArg, selfPath) is the testable core beneath it (hoisted from dispatch-gates.mjs, whose export is preserved as a re-export — nothing else imported it).

2. All 33 sites rewritten to if (isEntrypoint(import.meta.url)) { ... }, and 13 now-orphaned imports removed.

3. check:entry-guard — the class-closing gate. Only invoked-as.mjs may read process.argv[1]; require.main / import.meta.main / process.mainModule are rejected as second idioms; and isEntrypoint must be called on the caller's own url. Comments and string/template/regex literals are masked first via js-comment-mask.mjs, so the process.argv[1] inside run-with-stall-guard.mjs's child-process payload is excluded structurally rather than by an allowlist.

Following the #10196 precedent, the sweep alone was judged insufficient: nothing stopped a twelfth spelling, and the next one would be just as invisible.

Why a spelling gate rather than a behavioural sweep. Running every scripts/** entry point and asserting it produced output was rejected on measurement: several of these scripts have real side effects (release-github-releases, the sync-* pair, objectui-changeset-digest), and "produced output" is not decidable for an arbitrary tool — a quiet-on-success script is legitimate, so the assertion would be per-script, which is the same hand-wiring the gate replaces. The behavioural evidence therefore lives once, at the predicate, and enforcement covers the 33 callers and the 34th.

4. Aligned with the sibling predicate.packages/cli/src/utils/invocation.ts already exports isProcessEntry for the same reason (its header cites this card). It carries a directory legnode <dir> gives the entry argument directory resolution — that the scripts/ predicate lacked. Rather than ship two predicates answering this question differently, which is the defect being closed, invokedAs now carries the same two legs and both headers say to change them together. The duplication itself is structural and documented: scripts/ runs as plain .mjs against a possibly-unbuilt tree, so importing from a package would trade this bug for a worse one.

Verification

Reproduction and repair, across all 33 files. Each invoked --self-test directly, through a same-named symlink, and through a differently-named symlink, on origin/main (a second worktree) and on this branch:

treesymlink-inert
origin/main @ 923c42431 of 33
this branch0 of 33

The 2 non-inert on main are exactly the two the census predicts: dispatch-gates.mjs (already fixed) and qa-rollup.mjs (basename matcher). After the change all three invocation paths produce identical output for every script — the invocation path no longer changes behaviour, which is the actual contract.

Anti-vacuity control. The "0 remain" claim rests on the new gate's own scanner run against the unmodified origin/main tree — same gate code, both trees:

TREE=origin/main @ 923c4247 FILES=33 FINDINGS=50
TREE=this branch FILES=0 FINDINGS=0

The gate's self-test additionally drives all 11 measured spellings as fixture sources and asserts each is rejected, so its 0 is not a grep for one shape.

Ablation (both legs of the predicate).invoked-as.mjs is loaded from source by node — no dist/ in its resolution path — so there is no rebuild leg; each mutation was confirmed on disk by counting the removed text (→ 0) and the injected marker (→ 1) before running, and each restore by the reverse plus a cmp against the pre-ablation snapshot (byte-identical, both times).

  • Replacing the realpath comparison with return false2 of 11 cases red, both symlink legs, reported as {"out":"","status":0} — literally the defect shape.
  • Reducing the directory candidates to [entry]1 of 11 red, the node <dir> case.

Fixture regressions found and repaired. Three self-tests copy their own source into a synthetic checkout and spawn it there, so a newly-imported sibling broke them — a real cost of the shared-module approach, caught by running the suites rather than by reading. sync-template-versions.mjs, check-adr-0087-registration.mjs and objectui-changeset-digest.mjs (5 fixture sites across the three) now carry invoked-as.mjs with the copy. All three were red mid-change and are green now.

Gates.node scripts/pm/dispatch-gates.mjs with no path args, derived at f9a72c26 (the final commit), named 43 families; all 43 were run at that sha. 37 exit 0, including:

✓ check:entry-guard: 115 scripts/ file(s) — every entry guard goes through invoked-as.mjs.
✓ check-entry-guard self-test: 26 cases pass (all 11 measured spellings rejected, canonical form and masked prose/payloads accepted).
✓ invoked-as self-test: 11 cases pass (real symlink, different-name symlink, percent-encoding path, and both import directions).
✓ check-governed-merges --self-test: 81 assertions
✓ dispatch-gates self-test: 388 cases pass.
✓ check-nul-bytes: OK (scanned 6090 text file(s); no raw ASCII control bytes).

The 6 non-zero are environmental, each confirmed rather than assumed:

  • check-partof-closing-keyword, check-single-claim-paths, check-half-states (bare live-mode) — each prints its own NOT WIRED — … judged nothing … This is a wiring or usage failure, NOT a verdict for absent PR_BODY/PR_NUMBER. Their pnpm check:* self-test wrappers all pass.
  • check-prerelease-pin-watch — a standing board condition (act on #3653), unrelated to this diff.
  • check:published-readme-exports and check:type-check-debt — both demand a built workspace (whose type entry packages/…/dist/index.d.ts does not exist. Build first, and 55 workspace dependenc(ies) … have no built type entry point on disk). Both fail identically on unmodified origin/main, and this diff touches 0 files under packages/. lint.yml builds before these steps.

ESLint over scripts/: VERDICT command-exit 0.

Out of scope

skip-changeset: the diff is scripts/**, the package.json scripts block, and one lint.yml step — nothing published changes.

Filed rather than fixed here:

Fenced files checked, no intersection: scripts/publish-smoke.sh (#10212), scripts/check-test-completeness.mjs + .github/workflows/ci.yml (PR #10205), content/docs/deployment/** (#10229) — all carry zero occurrences of process.argv[1]. Nothing held by another dispatch was touched.


Patch round — the fixture-copy population my first census missed

CI on f9a72c26 failed Test Core (2/3) with 3 tests red in
packages/create-objectstack/src/template-version-stamps.test.ts:
Cannot find module './invoked-as.mjs' imported from /tmp/sync-template-versions-…/scripts/sync-template-versions.mjs.

Reproduced locally before changing anything, matching CI exactly:

❯ src/template-version-stamps.test.ts (8 tests | 3 failed) 216ms
Test Files 1 failed | 6 passed (7)
Tests 3 failed | 78 passed (81)

Why the first round missed it, stated plainly. I enumerated the consumers that copy a script into a synthetic tree inside scripts/ — the --self-test paths — and fixed those 5 sites. I never asked the wider question: what else in the repo materialises a copy of a scripts/*.mjs? The answer included a vitest suite under packages/, a population node scripts/pm/dispatch-gates.mjs does not reach, because it derives repo gate families and never runs packages/create-objectstack's tests. Nothing I ran in round one executed this file.

The complete census, re-derived

Every materialisation (copy or symlink) of the repo-root scripts/ tree, or of a file inside it, outside scripts/ itself — derived three independent ways (basename co-occurrence with a write verb; reads of a root-anchored scripts/ path feeding a write; and mkdir/cpSync/symlinkSync targeting a synthetic scripts directory):

consumershapestatus
packages/create-objectstack/src/template-version-stamps.test.tscopies one named filewas broken — fixed here
packages/spec/scripts/dist-freshness.test.tssymlinkSync(REPO_ROOT/scripts → fixture/scripts)safe by construction
packages/spec/scripts/dist-freshness-adoption.test.tssame whole-directory symlinksafe by construction
packages/spec/scripts/openapi-self-consistency.test.tscopies packages/spec/scripts, not rootnot in population

Plus the 5 fixture sites in 3 files insidescripts/ already repaired in f9a72c26. Four further packages/spec files import a root script in place (check-regen-pending.mjs), where the sibling resolves normally.

So: one broken consumer, and the two safe ones are safe precisely because they take the whole directory rather than a hand-picked file.

The shape, and why

A second hand-listed sibling would have been the same defect one turn later, so the fixture now derives the closure instead of naming files: it copies the script into the fixture at its repo-relative position, then does the same for every relative import it makes, transitively. The next sibling import travels on its own.

Rejected alternatives, both on measurement:

  • Copy the whole scripts/ tree (what the spec fixtures effectively do via symlink) — correct, but 6 MB and 207 files for a closure that is currently two.
  • Symlink the directory — not available here. Node resolves symlinks for the module graph, so the script would resolve its repo root to the real checkout instead of the fixture, which is the exact thing this copy exists to prevent (the fixture's own comment says the script self-locates via dirname(dirname(import.meta.url))).
  • Make the predicate reachable without a sibling file — that reopens the class this PR closes.

scripts/invoked-as.mjs is also now declared as a cross-package input for packages/create-objectstack in check-cross-package-test-inputs.mjs. The fixture derives the path rather than quoting it, so that gate's flat literal collector cannot see the read — but a change to the sibling really does break this test, and the declaration is what keeps the trigger radius honest.

Verification

  • Red → green on the named file, counts quoted from the runs themselves: before Tests 3 failed | 78 passed (81); after Test Files 7 passed (7) / Tests 81 passed (81). Verbose run confirms the file executed and names all 8 cases green, including the exact three that were red.

  • Ablation: replacing the closure walk with a no-op reproduces the CI failure precisely — Tests 3 failed | 5 passed (8), same ERR_MODULE_NOT_FOUND. Mutation and restore each confirmed on disk (removed text → 0, injected marker → 1, then the reverse plus a byte-identical cmp).

  • Every package the census implicates was run, not just the failing one: create-objectstack and @objectstack/spec.

  • packages/spec (the two whole-directory symlink fixtures, plus four in-place importers of check-regen-pending.mjs, which now pulls the sibling): Test Files 415 passed (415) / Tests 11049 passed (11049), zero ERR_MODULE_NOT_FOUND or invoked-as hits.

A second finding the re-run produced

Declaring scripts/invoked-as.mjs as a cross-package input made check:cross-package-test-inputs go red, and the refusal was the substantive half:

turbo.json "create-objectstack#test" inputs are missing the declared glob(s):
$TURBO_ROOT$/scripts/invoked-as.mjs

Without that input, turbo's cache would not invalidate when the sibling changes, so this test could go red on main while every PR reported green — issue #7802, and the exact shape that put this PR into a patch round. Added; the gate now reads OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.

Gates, re-derived at the final commit

node scripts/pm/dispatch-gates.mjs with no path args at d3d528e9 names 45 families — up from 43, because the diff now reaches packages/create-objectstack/src/**. The two additions (check:slot-lookup, check-affected-docs.mjs) were run and pass; this is the "gates no path derivation predicts" case the dispatch warned about, which is why the union was re-derived rather than reused.

All 45 run at d3d528e9: 39 pass, and the 6 non-zero are the same environmental set as the first round (4 print their own NOT WIRED … NOT a verdict for absent PR_BODY/PR_NUMBER; check:published-readme-exports and check:type-check-debt demand a built workspace and fail identically on unmodified origin/main). ESLint over scripts/ and the changed test: clean.


Generated by Claude Code


Generated by Claude Code

…dicate
The `invokedDirectly` guard was hand-typed in ELEVEN distinct spellings
across 33 files in `scripts/` (the card estimated ~8), and nine of them
were wrong in the same invisible direction: node resolves symlinks for
the module graph but leaves `process.argv[1]` as the caller typed it, so
a script reached through a symlink compared two different paths, answered
false, and did nothing -- exit 0, no output.
Measured on origin/main, 31 of 33 went inert through a symlink. The one
that matters most is the governed-surface register:
scripts/pm/check-governed-merges.mjs --test AGENTS.md
direct : exit=3, "GOVERNED -- no seat arms auto-merge"
symlink : exit=0, no output
and EXIT_TEST_NOT_GOVERNED is 0, so through a symlink the register's
"this PR is GOVERNED" answer and its "NOT governed, ordinary queue
landing applies" clearance are the same exit code.
- adds `scripts/invoked-as.mjs` -- one predicate, pinned by a self-test
that drives a real probe through a real symlink, a differently-named
symlink, a percent-encoding path, `node <dir>`, and both import
directions. Aligned leg-for-leg with the sibling predicate in
`packages/cli/src/utils/invocation.ts`.
- rewrites all 33 sites to `isEntrypoint(import.meta.url)`.
- adds `check:entry-guard`, the class-closing gate: only `invoked-as.mjs`
may read `process.argv[1]`, so a twelfth spelling cannot be typed.
Three self-tests copy their own source into a synthetic checkout and
spawn it there; they now carry the sibling module too.
Part of #10086
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…t a file list
CI on f9a72c2 failed Test Core (2/3): 3 tests red in
template-version-stamps.test.ts with
Cannot find module './invoked-as.mjs' imported from
/tmp/sync-template-versions-.../scripts/sync-template-versions.mjs
The fixture builds a synthetic checkout and copied exactly ONE named file,
so the sibling import this PR added to sync-template-versions.mjs was never
materialised. Nothing in the test mentioned the sibling, so nothing here had
to be edited for it to break.
The first round's census covered the copy-into-a-fixture consumers INSIDE
scripts/ (the --self-test paths) and missed this one: a vitest suite under
packages/, a population `dispatch-gates.mjs` does not reach because it
derives repo gate families and runs no package test suite.
Re-derived the full population three ways. Exactly one broken consumer;
packages/spec's two fixture builders symlink the WHOLE root scripts/ dir and
are safe by construction, which is the property this fix adopts.
The fixture now derives the closure -- copy the script, then every relative
import it makes, transitively -- so the next sibling import travels on its
own. Copying all of scripts/ (6 MB, 207 files) was rejected as disproportionate
for a two-file closure, and a directory symlink is not available here: node
resolves symlinks for the module graph, so the script would self-locate to the
real checkout instead of the fixture, which is what the copy exists to prevent.
Also declares scripts/invoked-as.mjs as a cross-package input for
create-objectstack -- the fixture derives the path rather than quoting it, so
the flat literal collector cannot see the read, but a change to the sibling
really does break this test.
Part of #10086
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Completes the declaration the previous commit started. `check:cross-package-
test-inputs` refused it:
turbo.json "create-objectstack#test" inputs are missing the declared glob(s):
$TURBO_ROOT$/scripts/invoked-as.mjs
and the refusal is the substantive half. template-version-stamps.test.ts now
copies the stamper's whole import closure into its fixture, so invoked-as.mjs
is a real input; without it in turbo's inputs the cache would not invalidate
on a change to the sibling, and the test could go red on main while every PR
reported green (#7802) -- which is the exact shape that put this PR into a
patch round.
Part of #10086
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

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

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-zhuang@claude