Skip to content

Commit f9a72c2

Browse files
committed
fix(scripts): route every entry guard through one symlink-correct predicate
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
1 parent 923c424 commit f9a72c2

37 files changed

Lines changed: 654 additions & 81 deletions

‎.github/workflows/lint.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,26 @@ jobs:
152152
- name: Raw control-byte guard
153153
run: pnpm check:nul-bytes
154154

155+
# Every `scripts/**` entry guard goes through ONE predicate (#10086).
156+
# The hand-typed forms of "did node run me, or did someone import me?"
157+
# had drifted into ELEVEN spellings across 33 files, and NINE were wrong
158+
# in the same invisible direction: node resolves symlinks for the module
159+
# graph but leaves `process.argv[1]` as the caller typed it, so a script
160+
# reached through a symlink compared two different paths, answered false,
161+
# and did NOTHING — exit 0, no output. The CI wrappers here hold the
162+
# child's exit STATUS only, so an inert child read as a green gate; the
163+
# governed-surface register (`scripts/pm/check-governed-merges.mjs`) was
164+
# among the affected, where the inert run and its "NOT governed, ordinary
165+
# queue landing applies" verdict are the SAME exit code.
166+
# The sweep alone would not have held — nothing stopped a twelfth
167+
# spelling. This gate is the part that closes the class: only
168+
# `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's
169+
# own self-test drives a real probe through a real symlink. Rationale and
170+
# the rejected behavioural-sweep alternative: the gate script's header.
171+
# Scans ~115 files, no spawns; ~0.2s.
172+
- name: scripts/ entry guards go through one predicate
173+
run: pnpm check:entry-guard
174+
155175
# Stack-collection enumerations vs the schema (#6242). `stack.zod.ts`
156176
# decides which collections a stack may declare; eight other enumerations
157177
# of that same set are hand-maintained (the map-format list, the

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs",
3535
"check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n",
3636
"check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs",
37+
"check:entry-guard": "node scripts/check-entry-guard.mjs --self-test && node scripts/check-entry-guard.mjs",
3738
"check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs",
3839
"check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs",
3940
"check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs",

‎scripts/check-adr-0087-registration.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod
305305
import { tmpdir } from 'node:os';
306306
import { dirname, join, resolve } from 'node:path';
307307
import { fileURLToPath } from 'node:url';
308+
import { isEntrypoint } from './invoked-as.mjs';
308309

309310
const __dirname = dirname(fileURLToPath(import.meta.url));
310311
const REPO_ROOT = resolve(__dirname, '..');
@@ -3640,6 +3641,9 @@ function selfTest() {
36403641
};
36413642
const copy = 'scripts/check-adr-0087-registration.mjs';
36423643
w(copy, readFileSync(fileURLToPath(import.meta.url), 'utf8'));
3644+
// The entry guard is imported from `scripts/invoked-as.mjs`, so the sibling
3645+
// travels with the copy or the fixture dies on ERR_MODULE_NOT_FOUND.
3646+
w('scripts/invoked-as.mjs', readFileSync(new URL('./invoked-as.mjs', import.meta.url), 'utf8'));
36433647
w(
36443648
'importer.mjs',
36453649
"import { readDisposition } from './scripts/check-adr-0087-registration.mjs';\n" +
@@ -3710,7 +3714,7 @@ function selfTest() {
37103714
// the I1/I2 self-test assertions pin BOTH halves of the separation -- silent as
37113715
// an import, unchanged as an entry point.
37123716

3713-
if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) {
3717+
if (isEntrypoint(import.meta.url)) {
37143718
const argv = process.argv.slice(2);
37153719
const readFlag = (name) => {
37163720
const i = argv.indexOf(name);

‎scripts/check-adr-links.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
110110
import { tmpdir } from 'node:os';
111111
import { dirname, join, relative, resolve } from 'node:path';
112-
import { fileURLToPath } from 'node:url';
112+
import { isEntrypoint } from './invoked-as.mjs';
113113

114114
const ADR_DIR = 'docs/adr';
115115

@@ -457,7 +457,7 @@ function selfTest() {
457457
/* Run only when invoked as a program. The extractor is exported so a future
458458
* caller (or a REPL session chasing a false positive) can import it without the
459459
* import itself sweeping the repo. */
460-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
460+
if (isEntrypoint(import.meta.url)) {
461461
if (process.argv.includes('--self-test')) selfTest();
462462
else runCheck();
463463
}

‎scripts/check-agent-model-declared.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
8383
import { tmpdir } from 'node:os';
8484
import { dirname, join, resolve } from 'node:path';
8585
import { fileURLToPath } from 'node:url';
86+
import { isEntrypoint } from './invoked-as.mjs';
8687

8788
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
8889
const AGENTS_DIR = '.claude/agents';
@@ -562,6 +563,6 @@ function main() {
562563
);
563564
}
564565

565-
if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) {
566+
if (isEntrypoint(import.meta.url)) {
566567
main();
567568
}

‎scripts/check-console-injection.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
readSpecBlob,
7878
readStamp,
7979
} from './console-spec-probes.mjs';
80+
import { isEntrypoint } from './invoked-as.mjs';
8081

8182
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8283

@@ -474,7 +475,7 @@ function selfTest() {
474475
// evaluate() from a script exited 0 with "no console dist" and never reached the
475476
// caller's code.
476477
const invokedDirectly =
477-
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
478+
isEntrypoint(import.meta.url);
478479

479480
if (!invokedDirectly) {
480481
// imported as a module — expose evaluate() and do nothing else

‎scripts/check-cross-repo-closer-outcome.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ import { execFileSync } from 'node:child_process';
109109
import { createRequire } from 'node:module';
110110
import { existsSync, readFileSync } from 'node:fs';
111111
import { join } from 'node:path';
112-
import { pathToFileURL } from 'node:url';
113112
import { isMap, isSeq, parseDocument } from 'yaml';
113+
import { isEntrypoint } from './invoked-as.mjs';
114114

115115
const WORKFLOW = '.github/workflows/cross-repo-issue-closer.yml';
116116
const JOB = 'close-foreign-issues';
@@ -1146,7 +1146,7 @@ async function selfTest() {
11461146
// reverse-verification route needs `extractScript` / `judge` pointed at another
11471147
// tree (a pre-fix checkout), and a module that runs its gate on import would
11481148
// silently judge THIS repo instead and print a pass about the wrong subject.
1149-
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
1149+
if (isEntrypoint(import.meta.url)) {
11501150
if (process.argv.includes('--self-test')) await selfTest();
11511151
else if (process.argv.includes('--list')) list();
11521152
else await main();

‎scripts/check-doc-anchors.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,10 @@
119119
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
120120
import { tmpdir } from 'node:os';
121121
import { dirname, join, relative, resolve } from 'node:path';
122-
import { fileURLToPath } from 'node:url';
123122
import Slugger from 'github-slugger';
124123

125124
import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs';
125+
import { isEntrypoint } from './invoked-as.mjs';
126126

127127
/**
128128
* The page population this gate sweeps, as the repo-relative glob it really
@@ -604,7 +604,7 @@ function selfTest() {
604604
/* Run only when invoked as a program — the extractor and the slug helpers are
605605
* exported so a caller chasing a false positive can import them without the
606606
* import itself sweeping the repo. */
607-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
607+
if (isEntrypoint(import.meta.url)) {
608608
if (process.argv.includes('--self-test')) selfTest();
609609
else runCheck();
610610
}

‎scripts/check-docs-image-tag.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
117117
import { tmpdir } from 'node:os';
118118
import { dirname, join, resolve } from 'node:path';
119119
import { fileURLToPath } from 'node:url';
120+
import { isEntrypoint } from './invoked-as.mjs';
120121

121122
/**
122123
* The package whose `version` every concrete pin below must equal.
@@ -736,7 +737,7 @@ function main() {
736737
// check-kernel-hook-pairs). Nothing about what this gate ASSERTS changes: both
737738
// `check:docs-image-tag` invocations run this file directly, where argv[1] is this
738739
// file and the branch is taken exactly as before.
739-
if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) {
740+
if (isEntrypoint(import.meta.url)) {
740741
if (process.argv.includes('--self-test')) {
741742
await selfTest();
742743
} else {

‎scripts/check-docs-redirects.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f
104104
import { tmpdir } from 'node:os';
105105
import { dirname, join, resolve } from 'node:path';
106106
import { fileURLToPath, pathToFileURL } from 'node:url';
107+
import { isEntrypoint } from './invoked-as.mjs';
107108

108109
/** The docs site's `baseUrl` (apps/docs/lib/source.ts). */
109110
const DOCS_BASE = '/docs';
@@ -689,7 +690,7 @@ async function main() {
689690
* `firstMatchingSource` are exported so a sibling gate can ask "would Fumadocs
690691
* serve this /docs/... URL?" without the import itself checking the redirect
691692
* table (and calling `process.exit` out from under its caller). */
692-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
693+
if (isEntrypoint(import.meta.url)) {
693694
if (process.argv.includes('--self-test')) {
694695
await selfTest();
695696
} else {

0 commit comments

Comments
 (0)