Measured while implementing the entry guard for #9757 (PR #10084). Not repaired there: that card's file surface is scripts/pm/dispatch-gates.mjs alone, and its dispatch ruled the repair comment/guard-only in that one file. Filed unassigned, severity for triage.
The measurement
Every entry guard in scripts/ compares process.argv[1] against import.meta.url. Node resolves symlinks for the module graph but leaves process.argv[1] as the caller typed it, so when a script is reached through a symlink the two name different paths, the guard answers false, and the script does nothing at all — with exit 0 and no output.
Run on main (b1457ee), each script invoked directly and then through a symlink to the same file:
scripts/pm/check-governed-merges.mjs --self-test
direct : exit=0, 1 line ✓ check-governed-merges --self-test: 81 assertions ...
symlink : exit=0, 0 lines (nothing at all)
scripts/js-comment-mask.mjs --self-test
direct : exit=0, 16 lines ✓ js-comment-mask self-test: 15 cases pass.
symlink : exit=0, 0 lines (nothing at all)
Both legs are exit 0. Nothing distinguishes "ran and passed" from "never ran" at the exit code, which is the only thing most of the CI wrappers hold.
Why it matters more than a cleanup
scripts/pm/check-governed-merges.mjs is the register behind Prime Directive #14 — its GOVERNED_SURFACES table is what AGENTS.md points at instead of its own prose, and its --test mode is the pre-arm predicate a seat is told to run on a PR's file list before arming auto-merge. Through a symlinked checkout that predicate prints nothing and exits 0, and EXIT_TEST_NOT_GOVERNEDis 0 (pinned in its own self-test at line 924). So the inert run and the "NOT governed, ordinary queue landing applies" answer are the same exit code. A seat reading the exit code rather than the printed verdict gets a clearance from a tool that never ran.
The same shape sits under pnpm check:pm-dispatch-gates and its siblings: the CI wrapper spawns the tool and holds result.status only, so an inert child is a green gate.
The spellings, measured on this tree
grep -rn "process.argv\[1\]" scripts/ finds at least eight distinct forms. Grouped by what each gets wrong:
resolve(argv[1]) === resolve(fileURLToPath(import.meta.url)) — check-governed-merges.mjs:648, sync-template-versions.mjs:767, check-objectui-pin-fresh.mjs:848, sync-docs-image-tags.mjs:587, check-prerelease-pin-watch.mjs:889, check-skill-frame-freshness.mjs:1042, check-docs-image-tag.mjs:739, check-agent-model-declared.mjs:565. The best of them; still symlink-blind.- the same without
resolve() on the right-hand side — release-github-releases.mjs:1012, ci-failure.mjs:1204, check-doc-anchors.mjs:607, check-docs-redirects.mjs:692, check-published-readme-exports.mjs:1320, check-adr-links.mjs:460, check-stack-collection-maps.mjs:829. import.meta.url === pathToFileURL(argv[1]).href — check-kernel-hook-pairs.mjs:448.import.meta.url === new URL(`file://${argv[1]}`).href — js-comment-mask.mjs:304. Breaks additionally on any path needing percent-encoding, since the template bypasses the encoder pathToFileURL applies.new URL(import.meta.url).pathname === argv[1] — git-history.mjs:507. Compares a percent-encoded pathname against a raw argv.- basename matching —
check-half-states.mjs:4149 (import.meta.url.endsWith(argv[1].split('/').pop())) and qa-rollup.mjs:944 (argv[1].endsWith('qa-rollup.mjs')). These answer true for any entry script sharing the basename, so they can fire on import as well as go inert.
The shape that survives
PR #10084 uses a two-comparison predicate in dispatch-gates.mjs and pins it with a real symlink fixture:
exportfunctioninvokedAs(entryArg,selfPath){if(!entryArg)returnfalse;constentry=resolve(entryArg);constself=resolve(selfPath);if(entry===self)returntrue;try{returnrealpathSync(entry)===realpathSync(self);}catch{returnfalse;}}Ablating the realpath half in that PR turns the symlink cases red (2 of 344), which is what establishes the case pins something rather than decorating the file.
Suggested shape for whoever takes this: one shared module exporting the predicate, imported by the scripts rather than re-typed — the same argument js-comment-mask.mjs and i18n-bundle-surface.mjs already won for their own duplicated judgments. A guard each file spells for itself is a guard each file can spell wrongly, and this one is wrong in a direction no gate can see.
Dedupe
search_issues for "entry guard spelling inconsistent across scripts, four different ways to compare process.argv[1] with import.meta.url, converge on one" returns 0 results. A second confirming search was refused by a GitHub API rate limit, so the dedupe rests on that one query plus #9757's own dedupe section, which found only the ADR-0087 precedent (#6566) and unrelated dispatch-gates derivation cards. #9757 names the spelling divergence in passing as "cheap while the file is open"; this card is that observation after measurement, and the measurement makes it larger than a tidy-up.
Generated by Claude Code
Measured while implementing the entry guard for #9757 (PR #10084). Not repaired there: that card's file surface is
scripts/pm/dispatch-gates.mjsalone, and its dispatch ruled the repair comment/guard-only in that one file. Filed unassigned, severity for triage.The measurement
Every entry guard in
scripts/comparesprocess.argv[1]againstimport.meta.url. Node resolves symlinks for the module graph but leavesprocess.argv[1]as the caller typed it, so when a script is reached through a symlink the two name different paths, the guard answers false, and the script does nothing at all — with exit 0 and no output.Run on
main(b1457ee), each script invoked directly and then through a symlink to the same file:Both legs are exit 0. Nothing distinguishes "ran and passed" from "never ran" at the exit code, which is the only thing most of the CI wrappers hold.
Why it matters more than a cleanup
scripts/pm/check-governed-merges.mjsis the register behind Prime Directive #14 — itsGOVERNED_SURFACEStable is what AGENTS.md points at instead of its own prose, and its--testmode is the pre-arm predicate a seat is told to run on a PR's file list before arming auto-merge. Through a symlinked checkout that predicate prints nothing and exits 0, andEXIT_TEST_NOT_GOVERNEDis 0 (pinned in its own self-test at line 924). So the inert run and the "NOT governed, ordinary queue landing applies" answer are the same exit code. A seat reading the exit code rather than the printed verdict gets a clearance from a tool that never ran.The same shape sits under
pnpm check:pm-dispatch-gatesand its siblings: the CI wrapper spawns the tool and holdsresult.statusonly, so an inert child is a green gate.The spellings, measured on this tree
grep -rn "process.argv\[1\]" scripts/finds at least eight distinct forms. Grouped by what each gets wrong:resolve(argv[1]) === resolve(fileURLToPath(import.meta.url))—check-governed-merges.mjs:648,sync-template-versions.mjs:767,check-objectui-pin-fresh.mjs:848,sync-docs-image-tags.mjs:587,check-prerelease-pin-watch.mjs:889,check-skill-frame-freshness.mjs:1042,check-docs-image-tag.mjs:739,check-agent-model-declared.mjs:565. The best of them; still symlink-blind.resolve()on the right-hand side —release-github-releases.mjs:1012,ci-failure.mjs:1204,check-doc-anchors.mjs:607,check-docs-redirects.mjs:692,check-published-readme-exports.mjs:1320,check-adr-links.mjs:460,check-stack-collection-maps.mjs:829.import.meta.url === pathToFileURL(argv[1]).href—check-kernel-hook-pairs.mjs:448.import.meta.url === new URL(`file://${argv[1]}`).href—js-comment-mask.mjs:304. Breaks additionally on any path needing percent-encoding, since the template bypasses the encoderpathToFileURLapplies.new URL(import.meta.url).pathname === argv[1]—git-history.mjs:507. Compares a percent-encoded pathname against a raw argv.check-half-states.mjs:4149(import.meta.url.endsWith(argv[1].split('/').pop())) andqa-rollup.mjs:944(argv[1].endsWith('qa-rollup.mjs')). These answer true for any entry script sharing the basename, so they can fire on import as well as go inert.The shape that survives
PR #10084 uses a two-comparison predicate in
dispatch-gates.mjsand pins it with a real symlink fixture:Ablating the realpath half in that PR turns the symlink cases red (2 of 344), which is what establishes the case pins something rather than decorating the file.
Suggested shape for whoever takes this: one shared module exporting the predicate, imported by the scripts rather than re-typed — the same argument
js-comment-mask.mjsandi18n-bundle-surface.mjsalready won for their own duplicated judgments. A guard each file spells for itself is a guard each file can spell wrongly, and this one is wrong in a direction no gate can see.Dedupe
search_issuesfor "entry guard spelling inconsistent across scripts, four different ways to compare process.argv[1] with import.meta.url, converge on one" returns 0 results. A second confirming search was refused by a GitHub API rate limit, so the dedupe rests on that one query plus #9757's own dedupe section, which found only the ADR-0087 precedent (#6566) and unrelated dispatch-gates derivation cards. #9757 names the spelling divergence in passing as "cheap while the file is open"; this card is that observation after measurement, and the measurement makes it larger than a tidy-up.Generated by Claude Code