From f9a72c26774c71c9d3c0463add0fa1ad1a529e72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 14:58:06 +0000 Subject: [PATCH 1/3] 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 `, 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 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .github/workflows/lint.yml | 20 ++ package.json | 1 + scripts/check-adr-0087-registration.mjs | 6 +- scripts/check-adr-links.mjs | 4 +- scripts/check-agent-model-declared.mjs | 3 +- scripts/check-console-injection.mjs | 3 +- scripts/check-cross-repo-closer-outcome.mjs | 4 +- scripts/check-doc-anchors.mjs | 4 +- scripts/check-docs-image-tag.mjs | 3 +- scripts/check-docs-redirects.mjs | 3 +- scripts/check-entry-guard.mjs | 275 ++++++++++++++++++ scripts/check-kernel-hook-pairs.mjs | 5 +- scripts/check-merge-queue-triage-outcome.mjs | 4 +- scripts/check-partof-closing-keyword.mjs | 3 +- scripts/check-prerelease-pin-watch.mjs | 3 +- scripts/check-published-readme-exports.mjs | 4 +- scripts/check-published-readme-links.mjs | 4 +- scripts/check-regen-pending.mjs | 3 +- scripts/check-single-claim-paths.mjs | 3 +- scripts/check-skill-compatibility-version.mjs | 5 +- scripts/check-skill-frame-freshness.mjs | 3 +- scripts/check-skill-frame-sync.mjs | 5 +- scripts/check-stack-collection-maps.mjs | 3 +- scripts/check-where-matcher-conformance.mjs | 3 +- scripts/invoked-as.mjs | 255 ++++++++++++++++ scripts/js-comment-mask.mjs | 4 +- scripts/objectui-changeset-digest.mjs | 14 +- scripts/objectui-range.mjs | 5 +- scripts/pm/check-governed-merges.mjs | 3 +- scripts/pm/check-half-states.mjs | 3 +- scripts/pm/ci-failure.mjs | 3 +- scripts/pm/dispatch-gates.mjs | 46 +-- scripts/pm/git-history.mjs | 5 +- scripts/qa/qa-rollup.mjs | 3 +- scripts/release-github-releases.mjs | 3 +- scripts/sync-docs-image-tags.mjs | 5 +- scripts/sync-template-versions.mjs | 12 +- 37 files changed, 654 insertions(+), 81 deletions(-) create mode 100644 scripts/check-entry-guard.mjs create mode 100644 scripts/invoked-as.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 12c90e10d3..ffd456af09 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -152,6 +152,26 @@ jobs: - name: Raw control-byte guard run: pnpm check:nul-bytes + # Every `scripts/**` entry guard goes through ONE predicate (#10086). + # The hand-typed forms of "did node run me, or did someone import me?" + # had drifted into ELEVEN spellings across 33 files, and NINE 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. The CI wrappers here hold the + # child's exit STATUS only, so an inert child read as a green gate; the + # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was + # among the affected, where the inert run and its "NOT governed, ordinary + # queue landing applies" verdict are the SAME exit code. + # The sweep alone would not have held — nothing stopped a twelfth + # spelling. This gate is the part that closes the class: only + # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's + # own self-test drives a real probe through a real symlink. Rationale and + # the rejected behavioural-sweep alternative: the gate script's header. + # Scans ~115 files, no spawns; ~0.2s. + - name: scripts/ entry guards go through one predicate + run: pnpm check:entry-guard + # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` # decides which collections a stack may declare; eight other enumerations # of that same set are hand-maintained (the map-format list, the diff --git a/package.json b/package.json index 8269df6369..1d5ef34fdc 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs", "check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", + "check:entry-guard": "node scripts/check-entry-guard.mjs --self-test && node scripts/check-entry-guard.mjs", "check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 6e20c1a28b..1a5907116a 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -305,6 +305,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -3640,6 +3641,9 @@ function selfTest() { }; const copy = 'scripts/check-adr-0087-registration.mjs'; w(copy, readFileSync(fileURLToPath(import.meta.url), 'utf8')); + // The entry guard is imported from `scripts/invoked-as.mjs`, so the sibling + // travels with the copy or the fixture dies on ERR_MODULE_NOT_FOUND. + w('scripts/invoked-as.mjs', readFileSync(new URL('./invoked-as.mjs', import.meta.url), 'utf8')); w( 'importer.mjs', "import { readDisposition } from './scripts/check-adr-0087-registration.mjs';\n" + @@ -3710,7 +3714,7 @@ function selfTest() { // the I1/I2 self-test assertions pin BOTH halves of the separation -- silent as // an import, unchanged as an entry point. -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { const argv = process.argv.slice(2); const readFlag = (name) => { const i = argv.indexOf(name); diff --git a/scripts/check-adr-links.mjs b/scripts/check-adr-links.mjs index eab3eed2ed..cc88723d47 100644 --- a/scripts/check-adr-links.mjs +++ b/scripts/check-adr-links.mjs @@ -109,7 +109,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const ADR_DIR = 'docs/adr'; @@ -457,7 +457,7 @@ function selfTest() { /* Run only when invoked as a program. The extractor is exported so a future * caller (or a REPL session chasing a false positive) can import it without the * import itself sweeping the repo. */ -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) selfTest(); else runCheck(); } diff --git a/scripts/check-agent-model-declared.mjs b/scripts/check-agent-model-declared.mjs index 8ea13ce064..6f2ebea950 100644 --- a/scripts/check-agent-model-declared.mjs +++ b/scripts/check-agent-model-declared.mjs @@ -83,6 +83,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const AGENTS_DIR = '.claude/agents'; @@ -562,6 +563,6 @@ function main() { ); } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { main(); } diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs index b5dba91a07..29982af0a0 100644 --- a/scripts/check-console-injection.mjs +++ b/scripts/check-console-injection.mjs @@ -77,6 +77,7 @@ import { readSpecBlob, readStamp, } from './console-spec-probes.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -474,7 +475,7 @@ function selfTest() { // evaluate() from a script exited 0 with "no console dist" and never reached the // caller's code. const invokedDirectly = - process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + isEntrypoint(import.meta.url); if (!invokedDirectly) { // imported as a module — expose evaluate() and do nothing else diff --git a/scripts/check-cross-repo-closer-outcome.mjs b/scripts/check-cross-repo-closer-outcome.mjs index f1a50cdf6d..637349cdf4 100644 --- a/scripts/check-cross-repo-closer-outcome.mjs +++ b/scripts/check-cross-repo-closer-outcome.mjs @@ -109,8 +109,8 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { isMap, isSeq, parseDocument } from 'yaml'; +import { isEntrypoint } from './invoked-as.mjs'; const WORKFLOW = '.github/workflows/cross-repo-issue-closer.yml'; const JOB = 'close-foreign-issues'; @@ -1146,7 +1146,7 @@ async function selfTest() { // reverse-verification route needs `extractScript` / `judge` pointed at another // tree (a pre-fix checkout), and a module that runs its gate on import would // silently judge THIS repo instead and print a pass about the wrong subject. -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) await selfTest(); else if (process.argv.includes('--list')) list(); else await main(); diff --git a/scripts/check-doc-anchors.mjs b/scripts/check-doc-anchors.mjs index b6021f1a56..7fe140fc5d 100644 --- a/scripts/check-doc-anchors.mjs +++ b/scripts/check-doc-anchors.mjs @@ -119,10 +119,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import Slugger from 'github-slugger'; import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; /** * The page population this gate sweeps, as the repo-relative glob it really @@ -604,7 +604,7 @@ function selfTest() { /* Run only when invoked as a program — the extractor and the slug helpers are * exported so a caller chasing a false positive can import them without the * import itself sweeping the repo. */ -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) selfTest(); else runCheck(); } diff --git a/scripts/check-docs-image-tag.mjs b/scripts/check-docs-image-tag.mjs index c2a62373dd..950ba579ad 100644 --- a/scripts/check-docs-image-tag.mjs +++ b/scripts/check-docs-image-tag.mjs @@ -117,6 +117,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; /** * The package whose `version` every concrete pin below must equal. @@ -736,7 +737,7 @@ function main() { // check-kernel-hook-pairs). Nothing about what this gate ASSERTS changes: both // `check:docs-image-tag` invocations run this file directly, where argv[1] is this // file and the branch is taken exactly as before. -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { await selfTest(); } else { diff --git a/scripts/check-docs-redirects.mjs b/scripts/check-docs-redirects.mjs index 233632385e..c076f51937 100644 --- a/scripts/check-docs-redirects.mjs +++ b/scripts/check-docs-redirects.mjs @@ -104,6 +104,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; /** The docs site's `baseUrl` (apps/docs/lib/source.ts). */ const DOCS_BASE = '/docs'; @@ -689,7 +690,7 @@ async function main() { * `firstMatchingSource` are exported so a sibling gate can ask "would Fumadocs * serve this /docs/... URL?" without the import itself checking the redirect * table (and calling `process.exit` out from under its caller). */ -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { await selfTest(); } else { diff --git a/scripts/check-entry-guard.mjs b/scripts/check-entry-guard.mjs new file mode 100644 index 0000000000..7d60f5bacb --- /dev/null +++ b/scripts/check-entry-guard.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-entry-guard -- every `scripts/**` entry guard goes through ONE predicate. + * + * node scripts/check-entry-guard.mjs # scan the tree + * node scripts/check-entry-guard.mjs --self-test # verify the checker itself + * + * ## What this gate is for + * + * A CLI script has to answer "did node run me, or did something import me?" + * before it does anything. Hand-typed answers to that question had drifted into + * ELEVEN distinct spellings across 33 files in `scripts/` -- measured, not + * estimated -- and NINE of them were wrong. The dominant failure: + * + * 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**. The CI wrappers spawn these + * tools and hold `result.status` only, so an inert child is a green gate. + * + * The sweep that fixed those 33 files is worth little on its own: nothing stopped + * a TWELFTH spelling from being typed the next time someone added a script, and + * the next one would be just as invisible. This gate is the part that closes the + * class. `scripts/invoked-as.mjs` is the only place allowed to read + * `process.argv[1]`; everywhere else spells the guard + * + * if (isEntrypoint(import.meta.url)) { ... } + * + * which has no comparison in it to get wrong. + * + * ## Why a spelling gate rather than a behavioural sweep + * + * The tempting alternative is to RUN every `scripts/**` entry point and assert + * it produced something. That was rejected on measurement: + * + * • many of these scripts have real side effects (`release-github-releases`, + * the `sync-*` pair, `objectui-changeset-digest`), so a gate that spawns all + * of them is a gate nobody can run locally; + * • "produced output" is not a decidable property of an arbitrary tool -- a + * quiet-on-success script is legitimate, so the assertion would have to be + * per-script, which is the same per-file hand-wiring this gate replaces. + * + * The behavioural evidence lives once, at the predicate: `invoked-as.mjs`'s + * self-test drives a real probe through a real symlink, a differently-named + * symlink, a path needing percent-encoding, and both import directions. Pinning + * the predicate once and enforcing that everyone uses it covers the same ground + * for 33 callers, and keeps covering it for the 34th. + * + * ## What it reads + * + * Comments AND string/template/regex literals are masked before the scan + * (`js-comment-mask.mjs`), because a `process.argv[1]` inside a string payload + * for a spawned child is not an entry guard -- `run-with-stall-guard.mjs` really + * does carry one, and an allowlist to excuse it would be a hole the next such + * file falls through silently. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { blank, scanSource } from './js-comment-mask.mjs'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const REPO_ROOT = resolve(HERE, '..'); +const SCRIPTS = HERE; + +/** The one module allowed to read `process.argv[1]`. */ +const PREDICATE_HOME = join(SCRIPTS, 'invoked-as.mjs'); + +/** The canonical guard, and the only accepted call shape. */ +export const CANONICAL = 'isEntrypoint(import.meta.url)'; + +/** + * Entry-guard idioms other than `process.argv[1]`. Each one answers the same + * question and each has its own way of being wrong under a symlink or a bundler, + * so none of them is a permitted second spelling. + */ +const OTHER_IDIOMS = [ + ['require.main', /\brequire\.main\b/g], + ['import.meta.main', /\bimport\.meta\.main\b/g], + ['process.mainModule', /\bprocess\.mainModule\b/g], +]; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name.startsWith('.')) continue; + const p = join(dir, name); + const st = statSync(p); + if (st.isDirectory()) walk(p, out); + else if (name.endsWith('.mjs') || name.endsWith('.js') || name.endsWith('.cjs')) out.push(p); + } + return out; +} + +/** Code only: comments, strings, templates and regex literals all blanked. */ +export function codeOnly(source) { + const { comment, literal } = scanSource(source); + const both = new Uint8Array(comment.length); + for (let i = 0; i < both.length; i++) both[i] = comment[i] || literal[i]; + return blank(source, both); +} + +function lineOf(source, index) { + return source.slice(0, index).split('\n').length; +} + +/** + * Findings for one file's source. Exported so the self-test drives the real + * scanner over fixture sources rather than over this tree, which would only + * prove what today's tree happens to contain. + */ +export function scanFile(rel, source, { isPredicateHome = false } = {}) { + const findings = []; + const code = codeOnly(source); + + if (!isPredicateHome) { + const re = /process\.argv\[1\]/g; + let m; + while ((m = re.exec(code))) { + findings.push({ + rel, + line: lineOf(source, m.index), + what: 'process.argv[1]', + why: 'a hand-typed entry guard', + }); + } + for (const [name, pattern] of OTHER_IDIOMS) { + pattern.lastIndex = 0; + let n; + while ((n = pattern.exec(code))) { + findings.push({ rel, line: lineOf(source, n.index), what: name, why: 'a second entry-guard idiom' }); + } + } + } + + // `isEntrypoint` takes the caller's own `import.meta.url`. Any other argument + // is a guard asking about somebody else, which is the same class of wrong. + // `(? cases.push({ name, ok: Boolean(ok), detail }); + const n = (src, opts) => scanFile('f.mjs', src, opts).length; + + // ── the eleven spellings this gate exists to reject ─────────────────────── + const SPELLINGS = [ + "const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));", + "const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);", + "if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) {}", + "if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {}", + "if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {}", + "if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {}", + "if (process.argv[1] && import.meta.url === new URL(`file://` + process.argv[1]).href) {}", + "const invokedDirectly = existsSync(process.argv[1] || '') && new URL(import.meta.url).pathname === process.argv[1];", + "const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());", + "const m = process.argv[1] && process.argv[1].endsWith('qa-rollup.mjs');", + "if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {}", + ]; + SPELLINGS.forEach((src, i) => t(`spelling ${i + 1} of ${SPELLINGS.length} is rejected`, n(src) > 0, src)); + + // ── the canonical form is accepted ──────────────────────────────────────── + t('the canonical guard is accepted', n(`if (${CANONICAL}) { main(); }`) === 0); + t('a file with no guard at all is accepted', n("console.log('hello');\n") === 0); + + // ── the predicate's own home may read argv ──────────────────────────────── + t( + 'invoked-as.mjs itself may read process.argv[1]', + n('return invokedAs(process.argv[1], fileURLToPath(u));', { isPredicateHome: true }) === 0, + ); + t( + '...and that exemption is NOT extended to any other file', + n('return invokedAs(process.argv[1], fileURLToPath(u));') > 0, + ); + + // ── prose and payloads are not guards ───────────────────────────────────── + t('a process.argv[1] in a LINE COMMENT is not a guard', n('// process.argv[1] is left as typed\n') === 0); + t('a process.argv[1] in a BLOCK COMMENT is not a guard', n('/**\n * process.argv[1] as typed\n */\n') === 0); + t( + 'a process.argv[1] inside a STRING payload for a child is not a guard', + n(`const s = 'require("fs").writeFileSync(process.argv[1], x)';\n`) === 0, + ); + t( + 'a process.argv[1] inside a TEMPLATE payload is not a guard', + n('const s = `node -e "f(process.argv[1])"`;\n') === 0, + ); + + // ── the other idioms ────────────────────────────────────────────────────── + t('require.main is rejected', n('if (require.main === module) {}') > 0); + t('import.meta.main is rejected', n('if (import.meta.main) {}') > 0); + t('process.mainModule is rejected', n('if (process.mainModule === module) {}') > 0); + + // ── the call shape ──────────────────────────────────────────────────────── + t('isEntrypoint on someone else’s url is rejected', n('if (isEntrypoint(other.url)) {}') > 0); + t('isEntrypoint(import.meta.url) is accepted', n('if (isEntrypoint(import.meta.url)) {}') === 0); + t( + 'the DECLARATION of the predicate is not read as a call on someone else', + n('export function isEntrypoint(importMetaUrl) {\n return invokedAs(process.argv[1], u);\n}', { isPredicateHome: true }) === 0, + ); + + // ── the line number is the one a reader can open ────────────────────────── + const multi = "line one\nline two\nconst g = process.argv[1] === x;\n"; + t('a finding reports the line the guard is ON', scanFile('f.mjs', multi)[0]?.line === 3, JSON.stringify(scanFile('f.mjs', multi))); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-entry-guard self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log(`✓ check-entry-guard self-test: ${cases.length} cases pass (all 11 measured spellings rejected, canonical form and masked prose/payloads accepted).`); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(process.argv.includes('--self-test') ? selfTest() : main()); +} diff --git a/scripts/check-kernel-hook-pairs.mjs b/scripts/check-kernel-hook-pairs.mjs index 4cd77c5e26..5e4973f67b 100644 --- a/scripts/check-kernel-hook-pairs.mjs +++ b/scripts/check-kernel-hook-pairs.mjs @@ -69,8 +69,9 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -445,7 +446,7 @@ function selfTest() { // working tree (e.g. `origin/main`, to prove a new gate has no false positives // before it is pinned in CI) — an import that audited, printed and possibly // called process.exit(1) as a side effect would make that impossible. -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) selfTest(); else if (process.argv.includes('--list')) list(); else run(); diff --git a/scripts/check-merge-queue-triage-outcome.mjs b/scripts/check-merge-queue-triage-outcome.mjs index 9c485b4274..6fccf12984 100644 --- a/scripts/check-merge-queue-triage-outcome.mjs +++ b/scripts/check-merge-queue-triage-outcome.mjs @@ -112,8 +112,8 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { isMap, isSeq, parseDocument } from 'yaml'; +import { isEntrypoint } from './invoked-as.mjs'; const WORKFLOW = '.github/workflows/merge-queue-triage.yml'; const JOB = 'triage'; @@ -1136,7 +1136,7 @@ async function selfTest() { // reverse-verification route needs `extractScript` / `judge` pointed at another // tree, and a module that ran its gate on import would silently judge THIS repo // instead and print a pass about the wrong subject. -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) await selfTest(); else if (process.argv.includes('--list')) list(); else await main(); diff --git a/scripts/check-partof-closing-keyword.mjs b/scripts/check-partof-closing-keyword.mjs index 2efed74f76..460021ceb8 100644 --- a/scripts/check-partof-closing-keyword.mjs +++ b/scripts/check-partof-closing-keyword.mjs @@ -127,6 +127,7 @@ import { join } from 'node:path'; import process from 'node:process'; import { h7PartOfWithClosingKeyword } from './pm/check-half-states.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = new URL('..', import.meta.url).pathname; @@ -354,7 +355,7 @@ function selfTest() { // The basename comparison, as in the sweep: this file is imported by nothing // today, but a future importer must not trigger a judgment as a side effect. -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); +const isMain = isEntrypoint(import.meta.url); if (isMain) { if (process.argv.includes('--self-test')) { selfTest(); diff --git a/scripts/check-prerelease-pin-watch.mjs b/scripts/check-prerelease-pin-watch.mjs index 20a3c82e64..e38f4bca2c 100644 --- a/scripts/check-prerelease-pin-watch.mjs +++ b/scripts/check-prerelease-pin-watch.mjs @@ -112,6 +112,7 @@ import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_WORKSPACE = join(REPO_ROOT, 'pnpm-workspace.yaml'); @@ -887,7 +888,7 @@ function selfTest() { return 0; } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { main(process.argv.slice(2)).then( (code) => process.exit(code), (err) => { diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 4bff138729..46151d347b 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -126,8 +126,8 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { isEntrypoint } from './invoked-as.mjs'; // Anchored to the script, not to cwd: the verdict must not depend on where the // guard was invoked from. @@ -1317,7 +1317,7 @@ function selfTest() { /* Run only when invoked as a program — `publishedDocs` and the extractors are * exported so a sibling gate can reuse this gate's population without the * import itself building a TypeScript program and sweeping the workspace. */ -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { selfTest(); process.exit(0); diff --git a/scripts/check-published-readme-links.mjs b/scripts/check-published-readme-links.mjs index 38b1756393..b5f05abc6d 100644 --- a/scripts/check-published-readme-links.mjs +++ b/scripts/check-published-readme-links.mjs @@ -100,12 +100,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; import { headingIds } from './check-doc-anchors.mjs'; import { docsRelative, firstMatchingSource, pageCandidates } from './check-docs-redirects.mjs'; import { publishedDocs } from './check-published-readme-exports.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = resolve(import.meta.dirname, '..'); const SELF = 'scripts/check-published-readme-links.mjs'; @@ -567,7 +567,7 @@ function selfTest() { /* Run only when invoked as a program — the extractor, the classifier and the * resolvers are exported so a caller chasing a false positive can import them * without the import itself sweeping the workspace. */ -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { selfTest(); process.exit(0); diff --git a/scripts/check-regen-pending.mjs b/scripts/check-regen-pending.mjs index 6a5bb47333..6cd402e133 100755 --- a/scripts/check-regen-pending.mjs +++ b/scripts/check-regen-pending.mjs @@ -71,6 +71,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PENDING_MARKER, entryForPath } from './regen-artifacts.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const SPEC_DIR = join(REPO_ROOT, 'packages/spec'); @@ -357,7 +358,7 @@ function main({ prePush = false } = {}) { // `check:generated --fix` imports `distIsStale` from here, so nothing may run on // import — only when this file IS the entry point. -const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +const invokedDirectly = isEntrypoint(import.meta.url); /** * Replay the deferred-merge sequence against a THROWAWAY git repo (#8047). diff --git a/scripts/check-single-claim-paths.mjs b/scripts/check-single-claim-paths.mjs index 8445533f9b..a1c6fa4ebb 100644 --- a/scripts/check-single-claim-paths.mjs +++ b/scripts/check-single-claim-paths.mjs @@ -146,6 +146,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import process from 'node:process'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = new URL('..', import.meta.url).pathname; @@ -546,7 +547,7 @@ function selfTest() { // The basename comparison, as in the sibling guard: a future importer must not // trigger a judgment as a side effect. -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); +const isMain = isEntrypoint(import.meta.url); if (isMain) { if (process.argv.includes('--self-test')) { await selfTest(); diff --git a/scripts/check-skill-compatibility-version.mjs b/scripts/check-skill-compatibility-version.mjs index 68d46712fc..0a7efec47c 100644 --- a/scripts/check-skill-compatibility-version.mjs +++ b/scripts/check-skill-compatibility-version.mjs @@ -62,8 +62,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const SKILLS_DIR = 'skills'; @@ -689,6 +690,6 @@ function main() { ); } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { main(); } diff --git a/scripts/check-skill-frame-freshness.mjs b/scripts/check-skill-frame-freshness.mjs index a5204033b6..a90b3c65dc 100644 --- a/scripts/check-skill-frame-freshness.mjs +++ b/scripts/check-skill-frame-freshness.mjs @@ -119,6 +119,7 @@ import { frameCountMentions, runAllChecks, } from './check-skill-frame-sync.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -1040,6 +1041,6 @@ function main() { process.stdout.write(text); } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { main(); } diff --git a/scripts/check-skill-frame-sync.mjs b/scripts/check-skill-frame-sync.mjs index b64c0ffbf7..932a92f29c 100644 --- a/scripts/check-skill-frame-sync.mjs +++ b/scripts/check-skill-frame-sync.mjs @@ -60,8 +60,9 @@ // node scripts/check-skill-frame-sync.mjs [--self-test] import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -856,6 +857,6 @@ function main() { // idiom as scripts/objectui-changeset-digest.mjs, which is imported the same way // by scripts/objectui-range.mjs (and was by check-objectui-pin-fresh.mjs, until // #10134 deleted it — the idiom is what carries over, not the importer). -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { main(); } diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index c2f2967aec..f955690fbe 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -63,6 +63,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..'); @@ -826,7 +827,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ // Run only when executed directly: the pure halves above are imported by the // self-test and by anything else that wants to ask what a site enumerates. -if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { +if (isEntrypoint(import.meta.url)) { const argv = process.argv.slice(2); if (argv.includes('--self-test')) process.exit(selfTest()); process.exit(run({ list: argv.includes('--list') })); diff --git a/scripts/check-where-matcher-conformance.mjs b/scripts/check-where-matcher-conformance.mjs index f537429e41..c7993b8d7f 100644 --- a/scripts/check-where-matcher-conformance.mjs +++ b/scripts/check-where-matcher-conformance.mjs @@ -223,6 +223,7 @@ import { join, relative, resolve, dirname } from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); @@ -878,7 +879,7 @@ function selfTest() { // Entry point // --------------------------------------------------------------------------- const invokedDirectly = - process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + isEntrypoint(import.meta.url); if (!invokedDirectly) { // Imported (the self-test of another gate, or a measurement helper). Running diff --git a/scripts/invoked-as.mjs b/scripts/invoked-as.mjs new file mode 100644 index 0000000000..58b1ac3e04 --- /dev/null +++ b/scripts/invoked-as.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * invoked-as -- the ONE answer to "was this module run, or imported?" + * + * node scripts/invoked-as.mjs --self-test + * + * Every CLI script in `scripts/` has to separate "node ran me" from "something + * imported me" before it decides whether to do anything. Each one used to + * answer that with its own hand-typed comparison of `process.argv[1]` against + * `import.meta.url`, and the copies had drifted into ELEVEN distinct spellings + * across 33 files -- measured, not estimated. Nine of the eleven were wrong, + * and wrong in a direction nothing in CI can see. + * + * ## The bug every hand-typed spelling had + * + * **Node resolves symlinks for the module graph but leaves `process.argv[1]` + * as the caller typed it.** Reach a script through a symlink and the two name + * different paths, the guard answers `false`, and the script does NOTHING -- + * with exit 0 and no output. + * + * That is the silent-success direction this tree treats as worse than no check + * at all. The CI wrappers spawn these tools and hold `result.status` only, so + * an inert child is a GREEN gate. Measured on the tree that motivated this + * module: + * + * 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, a human merge is the review record" answer and its + * "NOT governed, ordinary queue landing applies" answer are the SAME EXIT CODE. + * A seat reading the status rather than the printed verdict gets a clearance + * to arm auto-merge from a tool that never ran, on the one surface where human + * merge IS the review record (Prime Directive #14). + * + * ## The two other directions the copies failed in + * + * **Basename matching** -- `import.meta.url.endsWith(argv[1].split('/').pop())` + * and `argv[1].endsWith('qa-rollup.mjs')`. These survive a symlink that keeps + * the basename, so they look fine, but they answer TRUE for ANY entry script + * sharing the basename: they fire on IMPORT. The failure is the opposite + * direction -- a module that runs its whole CLI inside someone else's process. + * + * **Percent-encoding** -- `new URL(import.meta.url).pathname === argv[1]` + * compares an ENCODED pathname against a raw argv, and + * ``new URL(`file://${argv[1]}`)`` bypasses the encoder `pathToFileURL` + * applies. Both go inert on any checkout path containing a character that + * needs encoding, with no symlink involved at all. Measured: a `#` in any + * parent directory name is enough. + * + * ## The shape that survives + * + * Two comparisons. The plain `resolve` equality is the fast path and answers + * the ordinary case. The `realpath` comparison is the half that keeps a + * checkout REACHED THROUGH A SYMLINK from reading as "imported". It falls back + * to `false` rather than throwing -- an entry path that cannot be read is not + * this module. + * + * Comparing resolved PATHS (never URL strings) is what keeps percent-encoding + * out of the answer entirely: there is no encoder to disagree about. + * + * ## Why callers should reach for `isEntrypoint`, not `invokedAs` + * + * `isEntrypoint(import.meta.url)` takes ONE argument and reads `process.argv` + * itself, so a call site has nothing left to spell wrongly -- no `argv[1]`, no + * `fileURLToPath`, no comparison. `invokedAs` is the testable core underneath + * it, exported so the predicate is pinned by cases rather than trusted by + * reading. + * + * `scripts/check-entry-guard.mjs` enforces this: a `process.argv[1]` in an + * entry-guard position anywhere in `scripts/**` that is not this module is a + * failure. That gate is what stops a TWELFTH spelling, which is the whole + * reason this file exists rather than a one-time sweep. + * + * ## The sibling in `packages/cli`, and why the duplication is deliberate + * + * `packages/cli/src/utils/invocation.ts` exports `isProcessEntry`, the same + * predicate for the same reason (its header cites this defect). It is NOT + * imported here and this is not imported there: `scripts/` runs as plain .mjs + * against a possibly-unbuilt tree, and making the whole tooling layer depend on + * a package build to answer "was I run?" trades this bug for a worse one. + * + * The duplication is therefore structural, but DIVERGENCE is not allowed -- + * two predicates answering this question differently is precisely the defect + * being closed. Both carry the same two legs: realpath for symlinks, and + * directory resolution for `node `. Change one, change the other. + */ + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Is `entryArg` -- a `process.argv[1]` -- the module at `selfPath`? + * + * @param {string | undefined} entryArg `process.argv[1]`, as node left it. + * @param {string} selfPath An absolute filesystem path to the module asking. + * @returns {boolean} + */ +export function invokedAs(entryArg, selfPath) { + if (!entryArg) return false; + const self = resolve(selfPath); + const entry = resolve(entryArg); + + // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution: + // `argv[1]` can name the directory whose index this module is. Latent in + // `scripts/` today (nothing here is an `index`), carried because the sibling + // predicate in `packages/cli` carries it, and two predicates that answer this + // question differently is the defect this module exists to close. + const candidates = [entry, join(entry, 'index.mjs'), join(entry, 'index.js')]; + if (candidates.includes(self)) return true; + + const realSelf = realOrSelf(self); + return candidates.some((c) => realOrSelf(c) === realSelf); +} + +/** The realpath of `p`, or `p` itself when it cannot be read. */ +function realOrSelf(p) { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** + * Was the module identified by `importMetaUrl` run by node, rather than + * imported? The form every `scripts/**` entry guard should use: + * + * if (isEntrypoint(import.meta.url)) { ... } + * + * @param {string} importMetaUrl The caller's own `import.meta.url`. + * @returns {boolean} + */ +export function isEntrypoint(importMetaUrl) { + return invokedAs(process.argv[1], fileURLToPath(importMetaUrl)); +} + +// --------------------------------------------------------------------------- +// Self-test -- REAL symlinks, not a model of one +// --------------------------------------------------------------------------- + +/** + * The cases that matter here cannot be written as string comparisons, because + * the bug IS the difference between what node puts in `argv[1]` and what it + * puts in `import.meta.url`. So the fixture spawns a real probe module through + * a real symlink and reads what it printed. + * + * A model of a symlink would have passed against every one of the eleven + * broken spellings this module replaces. + */ +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); + + const SELF = fileURLToPath(import.meta.url); + + // ── the predicate, directly ──────────────────────────────────────────────── + t('an absent argv[1] is not this module -- the `node --eval` importer', !invokedAs(undefined, SELF) && !invokedAs('', SELF)); + t('an exact path is this module', invokedAs(SELF, SELF)); + t('a relative path resolving to this module is this module', invokedAs(relative(process.cwd(), SELF), SELF)); + t('an unrelated existing file is not this module', !invokedAs(resolve(SELF, '..', 'js-comment-mask.mjs'), SELF)); + t('an entry path that cannot be read is not this module (no throw)', !invokedAs(resolve(SELF, '..', 'no-such-file-here.mjs'), SELF)); + + // ── the fixture: a probe reached three ways ──────────────────────────────── + const dir = mkdtempSync(join(tmpdir(), 'invoked-as-')); + try { + // A directory whose name needs percent-encoding, because two of the + // replaced spellings went inert on exactly this with no symlink involved. + const deep = join(dir, 'a#b c'); + mkdirSync(deep, { recursive: true }); + + const probe = join(deep, 'probe.mjs'); + writeFileSync( + probe, + `import { isEntrypoint } from ${JSON.stringify(pathToFileURL(SELF).href)};\n` + + `if (isEntrypoint(import.meta.url)) console.log('RAN');\n`, + ); + + const sameName = join(dir, 'probe.mjs'); + const diffName = join(dir, 'not-the-same-name.mjs'); + symlinkSync(probe, sameName); + symlinkSync(probe, diffName); + + const run = (f) => { + const r = spawnSync(process.execPath, [f], { encoding: 'utf8' }); + return { out: (r.stdout || '').trim(), status: r.status }; + }; + + const direct = run(probe); + t('a probe run directly RUNS', direct.out === 'RAN' && direct.status === 0, JSON.stringify(direct)); + + // THE case. Every spelling this module replaces failed here, silently. + const viaSame = run(sameName); + t('a probe reached through a SYMLINK runs', viaSame.out === 'RAN' && viaSame.status === 0, JSON.stringify(viaSame)); + + // ...and not because the basenames happen to match: the two basename + // spellings that were replaced pass the case above and fail this one. + const viaDiff = run(diffName); + t('a probe reached through a symlink under a DIFFERENT NAME runs', viaDiff.out === 'RAN' && viaDiff.status === 0, JSON.stringify(viaDiff)); + + // The importer direction: the guard must stay false, or a module runs its + // whole CLI inside someone else's process. This is the direction the + // basename spellings got wrong. + const importer = join(dir, 'importer.mjs'); + writeFileSync(importer, `await import(${JSON.stringify(pathToFileURL(probe).href)});\nconsole.log('IMPORTED');\n`); + const imported = run(importer); + t('importing the probe does NOT run it', imported.out === 'IMPORTED' && imported.status === 0, JSON.stringify(imported)); + + // `node ` — the entry argument, and only it, gets directory + // resolution, so the index module must recognise the DIRECTORY as itself. + const pkgDir = join(dir, 'as-a-directory'); + mkdirSync(pkgDir, { recursive: true }); + // `node ` reaches the index through package.json `main` — the + // directory alone is not enough, which is why this leg needs a real fixture. + writeFileSync(join(pkgDir, 'package.json'), '{"type":"module","main":"index.mjs"}\n'); + writeFileSync( + join(pkgDir, 'index.mjs'), + `import { isEntrypoint } from ${JSON.stringify(pathToFileURL(SELF).href)};\n` + + `if (isEntrypoint(import.meta.url)) console.log('RAN');\n`, + ); + const viaDir = run(pkgDir); + t('a directory run as `node ` RUNS its index', viaDir.out === 'RAN' && viaDir.status === 0, JSON.stringify(viaDir)); + + // ...including when the importer shares the probe's basename, which is + // precisely what basename matching cannot tell apart. + const twinDir = join(dir, 'twin'); + mkdirSync(twinDir, { recursive: true }); + const twin = join(twinDir, 'probe.mjs'); + writeFileSync(twin, `await import(${JSON.stringify(pathToFileURL(probe).href)});\nconsole.log('IMPORTED');\n`); + const viaTwin = run(twin); + t('an importer sharing the probe basename does NOT run it', viaTwin.out === 'IMPORTED' && viaTwin.status === 0, JSON.stringify(viaTwin)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ invoked-as self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log(`✓ invoked-as self-test: ${cases.length} cases pass (real symlink, different-name symlink, percent-encoding path, and both import directions).`); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) process.exit(selfTest()); + console.log('usage: node scripts/invoked-as.mjs --self-test'); +} diff --git a/scripts/js-comment-mask.mjs b/scripts/js-comment-mask.mjs index 26f45ffca8..f761e14326 100644 --- a/scripts/js-comment-mask.mjs +++ b/scripts/js-comment-mask.mjs @@ -54,6 +54,8 @@ */ /** A character that can end an identifier -- i.e. a value, so `/` is division. */ +import { isEntrypoint } from './invoked-as.mjs'; + const IDENT_CHAR = /[\w$]/; /** @@ -301,7 +303,7 @@ export function selfTest() { // Executed only as a CLI. Importing this module must have NO side effect: the // gates below it are the callers, and a shared module that exits on import is // a shared module nobody can share. -if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) selfTest(); else { console.error('usage: node scripts/js-comment-mask.mjs --self-test'); diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 21d070bf05..46f57fd715 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -178,6 +178,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -1503,7 +1504,7 @@ function selfTest() { mkdirSync(join(fwRun, '.changeset'), { recursive: true }); writeFileSync(join(fwRun, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}\n'); writeFileSync(join(fwRun, '.objectui-sha'), `${base}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs']) { + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwRun, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } const bumpStdout = execFileSync( @@ -1544,7 +1545,7 @@ function selfTest() { mkdirSync(join(fwDegraded, 'scripts'), { recursive: true }); mkdirSync(join(fwDegraded, '.changeset'), { recursive: true }); writeFileSync(join(fwDegraded, '.objectui-sha'), `${'0'.repeat(40)}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs']) { + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwDegraded, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } execFileSync('bash', [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], { @@ -2026,6 +2027,9 @@ function selfTest() { 'scripts/check-adr-0087-registration.mjs', readFileSync(join(__dirname, 'check-adr-0087-registration.mjs'), 'utf8'), ); + // That gate imports its entry guard from `scripts/invoked-as.mjs`; the + // sibling travels with it or the copy dies on ERR_MODULE_NOT_FOUND. + gw('scripts/invoked-as.mjs', readFileSync(join(__dirname, 'invoked-as.mjs'), 'utf8')); gg('add', '-A'); gg('commit', '-q', '-m', 'base'); const gateBase = gg('rev-parse', 'HEAD').trim(); @@ -2383,7 +2387,7 @@ function selfTest() { mkdirSync(join(fwTrunc, 'scripts'), { recursive: true }); mkdirSync(join(fwTrunc, '.changeset'), { recursive: true }); writeFileSync(join(fwTrunc, '.objectui-sha'), `${c6from}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs']) { + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwTrunc, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } // OBJECTUI_NO_DEEPEN=1 on purpose: a self-test must never reach the network, @@ -2436,7 +2440,7 @@ function selfTest() { mkdirSync(join(fwTrunc2, 'scripts'), { recursive: true }); mkdirSync(join(fwTrunc2, '.changeset'), { recursive: true }); writeFileSync(join(fwTrunc2, '.objectui-sha'), `${c6from}\n`); - for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs']) { + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwTrunc2, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } const noopDeepen = spawnSync('bash', [join(fwTrunc2, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { @@ -2480,6 +2484,6 @@ function selfTest() { return 0; } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { process.exit(main(process.argv.slice(2))); } diff --git a/scripts/objectui-range.mjs b/scripts/objectui-range.mjs index c80a21cc78..03762eb10c 100644 --- a/scripts/objectui-range.mjs +++ b/scripts/objectui-range.mjs @@ -74,10 +74,11 @@ // remaining sections still key on the declared level. import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join } from 'node:path'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { classifyRange, clampSummary } from './objectui-changeset-digest.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); @@ -615,6 +616,6 @@ function selfTest() { return 0; } -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { process.exit(has('--self-test') ? selfTest() : main()); } diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 4da69b1b3f..29e276ab81 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -252,6 +252,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; const scriptPath = fileURLToPath(import.meta.url); const scriptDir = dirname(scriptPath); @@ -645,7 +646,7 @@ export function renderReport({ sinceIso, repos, scanned, entries, lookups }) { // ── CLI ───────────────────────────────────────────────────────────────────── -const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +const invokedDirectly = isEntrypoint(import.meta.url); function runTestMode(args) { const i = args.indexOf('--test'); diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 66d27393e4..5d880a5ed8 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -504,6 +504,7 @@ import process from 'node:process'; import { execFileSync } from 'node:child_process'; +import { isEntrypoint } from '../invoked-as.mjs'; const OWNER_REPO = process.env.PM_SWEEP_REPO ?? 'objectstack-ai/objectstack'; const API = 'https://api.github.com'; @@ -5002,7 +5003,7 @@ function selfTest() { console.log(`✓ check-half-states self-test: ${cases.length} cases pass.`); } -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); +const isMain = isEntrypoint(import.meta.url); if (isMain) { if (process.argv.includes('--self-test')) { selfTest(); diff --git a/scripts/pm/ci-failure.mjs b/scripts/pm/ci-failure.mjs index 3837d4835b..cdb0265666 100644 --- a/scripts/pm/ci-failure.mjs +++ b/scripts/pm/ci-failure.mjs @@ -198,6 +198,7 @@ import { parseRemaining, } from './check-half-states.mjs'; import { PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './check-governed-merges.mjs'; +import { isEntrypoint } from '../invoked-as.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..', '..'); @@ -1378,7 +1379,7 @@ async function selfTest() { // Entry point // --------------------------------------------------------------------------- -const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +const invokedDirectly = isEntrypoint(import.meta.url); if (!invokedDirectly) { // Imported (a sibling's self-test, or a measurement helper). Running the walk diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 8571511747..73b81d55e3 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -156,6 +156,7 @@ import { isMetadataFormModulePath, } from '../i18n-bundle-surface.mjs'; import { blank, maskComments, scanSource } from '../js-comment-mask.mjs'; +import { invokedAs, isEntrypoint } from '../invoked-as.mjs'; // Re-exported so this tool's self-test drives the SAME predicates the gate // runs, not copies of them. They used to be written twice — see the shared @@ -4088,40 +4089,21 @@ function selfTest() { // ── CLI ───────────────────────────────────────────────────────────────────── /** - * Is `entryArg` — a `process.argv[1]` — this very module? - * - * Exported so the predicate the entry guard stands on is pinned by cases rather - * than trusted by reading. Its failure direction is SILENT: an `invokedDirectly` - * that wrongly answered `false` would turn every mode of this tool into a no-op - * that prints nothing and exits 0, and `check:pm-dispatch-gates` holds the - * child's exit STATUS only (see that gate's header) — so the no-op would report - * as a pass, which is the silent-success direction this tree treats as worse - * than no check at all. - * - * Two comparisons, because node resolves symlinks for the module graph but - * leaves `process.argv[1]` as the caller typed it. The plain `resolve` equality - * is the spelling of the landed precedent one file over - * (`scripts/pm/check-governed-merges.mjs`, whose header carries this shape's - * incident history). The realpath comparison is the half that keeps a checkout - * REACHED THROUGH A SYMLINK from reading as "imported": `import.meta.url` would - * name the real file while `argv[1]` named the link, the equality would answer - * false, and the tool would go quietly inert for whoever ran it that way. It - * falls back to `false` rather than throwing — an unreadable entry path is not - * this module. + * The entry guard, and the predicate under it, both live in + * `scripts/invoked-as.mjs` — one implementation for all of `scripts/`. + * + * `invokedAs` is re-exported because this module's self-test drives it + * directly, and because that export was this tree's first landing of the + * two-comparison shape. The implementation moved; the export did not. + * + * Its failure direction is SILENT: an entry guard that wrongly answered + * `false` would turn every mode of this tool into a no-op that prints nothing + * and exits 0, and `check:pm-dispatch-gates` holds the child's exit STATUS + * only (see that gate's header) — so the no-op would report as a pass. */ -export function invokedAs(entryArg, selfPath) { - if (!entryArg) return false; - const entry = resolve(entryArg); - const self = resolve(selfPath); - if (entry === self) return true; - try { - return realpathSync(entry) === realpathSync(self); - } catch { - return false; - } -} +export { invokedAs }; -const invokedDirectly = invokedAs(process.argv[1], fileURLToPath(import.meta.url)); +const invokedDirectly = isEntrypoint(import.meta.url); /** * Executed only as a CLI. Importing this module must have NO side effect. diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index 37b5efce25..dcd301455a 100644 --- a/scripts/pm/git-history.mjs +++ b/scripts/pm/git-history.mjs @@ -75,9 +75,10 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { isEntrypoint } from '../invoked-as.mjs'; const DEFAULT_REF = 'origin/main'; /** Slack applied below `--since` when deepening, absorbing commit-date skew. */ @@ -504,7 +505,7 @@ function selfTest() { return failures === 0 ? 0 : 1; } -const invokedDirectly = existsSync(process.argv[1] || '') && new URL(import.meta.url).pathname === process.argv[1]; +const invokedDirectly = isEntrypoint(import.meta.url); if (invokedDirectly) { process.exit(main(process.argv.slice(2)) || 0); } diff --git a/scripts/qa/qa-rollup.mjs b/scripts/qa/qa-rollup.mjs index 0a3f725f6e..239516899e 100755 --- a/scripts/qa/qa-rollup.mjs +++ b/scripts/qa/qa-rollup.mjs @@ -86,6 +86,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { isEntrypoint } from '../invoked-as.mjs'; /** The verdict vocabulary, in report order. */ export const VERDICTS = ['PASS', 'PARTIAL', 'FAIL', 'BLOCKED', 'NOT-RUN']; @@ -941,6 +942,6 @@ async function selfTest() { if (process.argv.includes('--self-test')) { await selfTest(); -} else if (process.argv[1] && process.argv[1].endsWith('qa-rollup.mjs')) { +} else if (isEntrypoint(import.meta.url)) { await main(process.argv.slice(2)); } diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs index f46bb6d483..7555947d0b 100644 --- a/scripts/release-github-releases.mjs +++ b/scripts/release-github-releases.mjs @@ -77,6 +77,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -1009,7 +1010,7 @@ async function selfTest() { ); } -const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +const invokedDirectly = isEntrypoint(import.meta.url); if (invokedDirectly) { try { if (process.argv.includes('--self-test')) { diff --git a/scripts/sync-docs-image-tags.mjs b/scripts/sync-docs-image-tags.mjs index cc82794401..d2282e10d5 100644 --- a/scripts/sync-docs-image-tags.mjs +++ b/scripts/sync-docs-image-tags.mjs @@ -77,7 +77,7 @@ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, statSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -89,6 +89,7 @@ import { isConcreteVersion, loadExpectedVersion, } from './check-docs-image-tag.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; /** The repo this script lives in -- resolved from the script, so cwd cannot lie. */ function scriptRepoRoot() { @@ -584,7 +585,7 @@ function selfTest() { // Entry-point guard, for the reason #9064 exists: this file is importable, and an // import that rewrote three doc surfaces as a side effect would be strictly worse than // the gate's version of the same bug. -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { selfTest(); } else { diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index 2dbd93e5eb..31a73d4267 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -97,7 +97,8 @@ import { import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; /** The repo this script lives in — resolved from the script, so cwd cannot lie. */ const root = dirname(dirname(fileURLToPath(import.meta.url))); @@ -482,6 +483,13 @@ function writeFixtureFile(file, contents) { function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = {}) { const script = join(dir, 'scripts', 'sync-template-versions.mjs'); writeFixtureFile(script, readFileSync(fileURLToPath(import.meta.url), 'utf8')); + // The entry guard is imported, not re-typed (`scripts/invoked-as.mjs`), so the + // fixture checkout needs the sibling too — without it the copied script dies + // on ERR_MODULE_NOT_FOUND instead of running. + writeFixtureFile( + join(dir, 'scripts', 'invoked-as.mjs'), + readFileSync(new URL('./invoked-as.mjs', import.meta.url), 'utf8'), + ); writeFixtureFile( join(dir, VERSION_SOURCE), JSON.stringify({ name: 'create-objectstack', version: SELF_TEST_VERSION }, null, 2) + '\n', @@ -764,7 +772,7 @@ function selfTest() { // and for the same reason: this file is importable, and an import that rewrote // every bundled template as a side effect is strictly worse than the missing // export it was working around. -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { +if (isEntrypoint(import.meta.url)) { if (process.argv.includes('--self-test')) { selfTest(); } else { From c2afe711fe56613b3662dde13e5ab8f120673aa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:26:01 +0000 Subject: [PATCH 2/3] fix(create-objectstack): derive the fixture's script copy closure, not a file list CI on f9a72c26 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 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .../src/template-version-stamps.test.ts | 41 ++++++++++++++++++- scripts/check-cross-package-test-inputs.mjs | 10 +++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts index cce8b44544..48ba146ed3 100644 --- a/packages/create-objectstack/src/template-version-stamps.test.ts +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -88,6 +88,45 @@ const writeFixtureFile = (file: string, content: string) => { fs.writeFileSync(file, content); }; +/** + * Copy `absFile` into the fixture at the SAME repo-relative position, then do + * the same for every RELATIVE import it makes, transitively. + * + * Hand-listing the file was the bug: this fixture used to copy exactly one + * named script, and when `sync-template-versions.mjs` gained an + * `import { isEntrypoint } from './invoked-as.mjs'` (#10086) the copy began + * throwing ERR_MODULE_NOT_FOUND on its first statement — both when imported + * (`loadSync`) and when spawned. Nothing in this file mentioned the sibling, so + * nothing here had to be edited for it to break. + * + * Deriving the closure removes the class rather than the instance: the NEXT + * sibling import someone adds to a copied script travels on its own. Copying + * the whole `scripts/` tree would also work, but it is 6 MB and 207 files for a + * closure that is currently two; and a directory SYMLINK is not an option here + * because node resolves symlinks for the module graph, so the script would + * resolve its repo root to the real checkout instead of this fixture — which is + * the very thing the copy exists to prevent. + */ +const copyWithLocalImports = (absFile: string, seen = new Set()): void => { + if (!fs.existsSync(absFile)) return; + const real = fs.realpathSync(absFile); + if (seen.has(real)) return; + seen.add(real); + + const rel = path.relative(repoRoot, real); + // Only files inside the checkout have a meaningful position in the fixture. + if (rel.startsWith('..') || path.isAbsolute(rel)) return; + + const source = fs.readFileSync(real, 'utf8'); + writeFixtureFile(path.join(fixture, rel), source); + + const specifiers = [ + ...source.matchAll(/\bfrom\s+'(\.[^']*)'/g), + ...source.matchAll(/\bimport\s*\(\s*'(\.[^']*)'/g), + ]; + for (const m of specifiers) copyWithLocalImports(path.resolve(path.dirname(real), m[1]), seen); +}; + beforeAll(() => { fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-9554-')); @@ -95,7 +134,7 @@ beforeAll(() => { // (`dirname(dirname(import.meta.url))`), so a copy two levels above the // template tree makes the fixture a complete, self-consistent checkout. fixtureScript = path.join(fixture, 'scripts', 'sync-template-versions.mjs'); - writeFixtureFile(fixtureScript, fs.readFileSync(SYNC_SCRIPT, 'utf8')); + copyWithLocalImports(SYNC_SCRIPT); writeFixtureFile( path.join(fixture, 'packages', 'create-objectstack', 'package.json'), diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 64643f53e1..ff30622781 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -430,6 +430,16 @@ const CROSS_PACKAGE_TEST_INPUTS = { globs: [ 'content/**', 'scripts/sync-template-versions.mjs', + // The stamper's own import closure, and a live input for the same reason + // the stamper is: template-version-stamps.test.ts copies the script into + // a fixture and both IMPORTS and SPAWNS it there, so the copy needs every + // relative import the script makes. That fixture derives the closure + // rather than naming files, so this path appears in NO quoted string the + // flat literal collector can see — but a change to it really does break + // that test (measured: drop the closure walk and the same 3 cases fail + // with ERR_MODULE_NOT_FOUND), which is exactly the trigger radius this + // declaration exists to keep honest. + 'scripts/invoked-as.mjs', '.github/workflows/scaffold-e2e.yml', 'packages/cli/src/commands/serve.ts', 'scripts/gen-sdui-manifest.sh', From d3d528e9ef9623478839249ca362813cf821b857 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:35:24 +0000 Subject: [PATCH 3/3] fix(turbo): hash scripts/invoked-as.mjs for create-objectstack#test 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 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index ad27efb008..568f6e1690 100644 --- a/turbo.json +++ b/turbo.json @@ -199,6 +199,7 @@ "!.turbo/**", "$TURBO_ROOT$/content/**", "$TURBO_ROOT$/scripts/sync-template-versions.mjs", + "$TURBO_ROOT$/scripts/invoked-as.mjs", "$TURBO_ROOT$/.github/workflows/scaffold-e2e.yml", "$TURBO_ROOT$/packages/cli/src/commands/serve.ts", "$TURBO_ROOT$/scripts/gen-sdui-manifest.sh",