diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index ad468628ea..d835cfa2b6 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -87,7 +87,9 @@ // prerequisite failure naming the package whose dist is stale — never a count. import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { + atRepoRoot, CLI, CLI_BUILD_FIX, looksLikeMissingCliCommand, @@ -380,6 +382,55 @@ function selfTest() { const undeclaredTarget = oclifCommandFileFor({ oclif: {} }, ['i18n', 'extract']); expect('#5217 unreadable shape defers, loudly', !!undeclaredTarget.unknown && !undeclaredTarget.file, `an unreadable oclif block must yield a reason, not a guessed path; got ${JSON.stringify(undeclaredTarget)}`); + // …and the probe that puts the REAL package.json through it is anchored to the + // module's own location, not the cwd (#11394). Before that fix this gate could + // not pre-check from anywhere but the repo root — it printed `build prerequisite + // not pre-checked` and deferred. Both halves of the seam are pinned, because + // fixing only the first would have been WORSE than the defect: a resolving probe + // over a CWD-relative existence check reports "the workspace CLI is not built" + // about a CLI that is built. + const probeCwdBefore = process.cwd(); + const onRootProbe = resolveCliCommandFile(EXTRACT_COMMAND_ID); + let offRootProbe; + let anchoredReadOffRoot; + let bareReadOffRoot; + try { + process.chdir(tmpdir()); + offRootProbe = resolveCliCommandFile(EXTRACT_COMMAND_ID); + anchoredReadOffRoot = existsSync(atRepoRoot('scripts/check-i18n-bundles.mjs')); + bareReadOffRoot = existsSync('scripts/check-i18n-bundles.mjs'); + } finally { + process.chdir(probeCwdBefore); + } + expect( + '#11394 the shared CLI probe answers a path, not a deferral', + !!onRootProbe.file && !onRootProbe.unknown, + `over this repo the probe must derive the command file; got ${JSON.stringify(onRootProbe)}`, + ); + expect( + '#11394 …and answers the same from any cwd', + JSON.stringify(offRootProbe) === JSON.stringify(onRootProbe), + `off-root the probe said ${JSON.stringify(offRootProbe)}, on-root ${JSON.stringify(onRootProbe)}`, + ); + expect( + '#11394 …spelled repo-relative, as `CLI` and every message here are', + typeof onRootProbe.file === 'string' && !onRootProbe.file.startsWith('/'), + `anchoring the READ must not leak into the vocabulary; got ${JSON.stringify(onRootProbe)}`, + ); + // The consumer side of the same seam, proven with a read that is true on ANY + // tree: `--self-test` runs with no build, so comparing `existsSync` on the + // command file itself would agree over nothing from both cwds. + expect( + '#11394 an anchored read lands on the repo from a foreign cwd', + anchoredReadOffRoot, + 'atRepoRoot() did not reach this repo from ' + tmpdir(), + ); + expect( + '#11394 …and the bare spelling demonstrably would not have', + !bareReadOffRoot, + 'the bare spelling resolved off-root too, so this assertion proves nothing about anchoring', + ); + // ------------------------------------------------------------------------- // Fourth classifier (#7681): the OTHER prerequisite — a workspace package this // gate loads whose build output no longer matches its source. Pinned here or @@ -673,7 +724,16 @@ function checkCliBuildPrerequisite() { console.error(`check-i18n-bundles: ${resolved.unknown} — build prerequisite not pre-checked`); return; } - if (existsSync(resolved.file)) return; + // `resolved.file` is repo-relative (`packages/cli/dist/commands/i18n/extract.js`), + // so the EXISTENCE check on it needs the same anchor the read behind it got + // (#11394). Unanchored, an off-root run that got this far would report "the + // workspace CLI is not built" about a CLI that IS built — the #5862 defect (a + // confident diagnosis pointing somewhere innocent) rebuilt one layer down, and + // the exact sentence `check-i18n-coverage.mjs` already carries over its own copy + // of this line. Before #11394 the probe could not get here from a foreign cwd at + // all, so anchoring the read without anchoring this would have traded a harmless + // deferral for a false hard failure. + if (existsSync(atRepoRoot(resolved.file))) return; reportPrerequisiteNotMet('the workspace CLI is not built', [ `This gate runs the BUILT CLI. ${CLI} is only a source stub that hands`, `off to oclif, which resolves \`os ${EXTRACT_COMMAND_ID.join(' ')}\` from the compiled`, diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 0c157bd8d3..433a91a1c0 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -885,6 +885,67 @@ function selfTest() { `absolute paths would silently re-key every baseline entry; got ${JSON.stringify(offRoot.slice(0, 2))}`, ); + // The SHARED probe's own anchoring (#11394). #10907 anchored this FILE; the + // module it asks "is the CLI built?" kept reading `packages/cli/package.json` + // CWD-relatively, so from any other cwd the probe ENOENTed and deferred — and + // every off-root run was preceded by a line about a workspace that was fine. + // Pinned here rather than in the shared module because the module has no + // self-test of its own: both of its consumers pin its classifiers, and this is + // the one property of it that a recorded string cannot prove. + const probeCwdBefore = process.cwd(); + let offRootProbe; + try { + process.chdir(tmpdir()); + offRootProbe = resolveCliCommandFile(LINT_COMMAND_ID); + } finally { + process.chdir(probeCwdBefore); + } + const onRootProbe = resolveCliCommandFile(LINT_COMMAND_ID); + expect( + '#11394 the shared CLI probe answers a path, not a deferral', + !!onRootProbe.file && !onRootProbe.unknown, + `over this repo the probe must derive the command file; got ${JSON.stringify(onRootProbe)}`, + ); + expect( + '#11394 …and answers the same from any cwd', + JSON.stringify(offRootProbe) === JSON.stringify(onRootProbe), + `off-root the probe said ${JSON.stringify(offRootProbe)}, on-root ${JSON.stringify(onRootProbe)} — ` + + 'the read behind it is CWD-relative again', + ); + expect( + '#11394 …spelled repo-relative, as every message and rerun command is', + typeof onRootProbe.file === 'string' && !onRootProbe.file.startsWith('/') && !onRootProbe.file.includes(REPO_ROOT), + `anchoring the READ must not leak into the vocabulary; got ${JSON.stringify(onRootProbe)}`, + ); + // …and the consumer side of the same seam: this gate asks the FILESYSTEM about + // that repo-relative answer, and `at()` is what makes the question land on the + // repo rather than the cwd. Unanchored it would report "the workspace CLI is not + // built" from a foreign cwd over a built one — which is why the probe resolving + // and this check being anchored are one change, not two. + // + // Proven with a read that is true on ANY tree rather than by comparing + // `existsSync` on the command file: `--self-test` runs with no build, where that + // file is absent from both cwds and the comparison would agree over nothing. + let anchoredReadOffRoot; + let bareReadOffRoot; + try { + process.chdir(tmpdir()); + anchoredReadOffRoot = existsSync(at('scripts/check-i18n-coverage.mjs')); + bareReadOffRoot = existsSync('scripts/check-i18n-coverage.mjs'); + } finally { + process.chdir(probeCwdBefore); + } + expect( + '#11394 an anchored read lands on the repo from a foreign cwd', + anchoredReadOffRoot, + `at() did not reach this repo from ${tmpdir()} — REPO_ROOT is ${REPO_ROOT}`, + ); + expect( + '#11394 …and the bare spelling demonstrably would not have', + !bareReadOffRoot, + 'the bare spelling resolved off-root too, so this assertion proves nothing about anchoring', + ); + // Anti-#4690 on the population itself. Red on nothing is the assertion that // matters — this is the one verdict whose failure mode is a green line. expect('#10907 an empty population is refused', !!emptyPopulationVerdict([]), 'zero configs must never be a pass'); diff --git a/scripts/cli-build-prerequisite.mjs b/scripts/cli-build-prerequisite.mjs index a22b727c89..603df73115 100644 --- a/scripts/cli-build-prerequisite.mjs +++ b/scripts/cli-build-prerequisite.mjs @@ -25,8 +25,34 @@ // CLI and a stale dependency are different facts with different remedies, and the // two signatures must not match each other's corpus. import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const HERE = dirname(fileURLToPath(import.meta.url)); +/** This module lives in `scripts/`, so the repo root is one level up (#11394). */ +const REPO_ROOT = resolve(HERE, '..'); + +/** + * A repo-relative path, resolved against the module-derived root — the ONE seam + * between this module's vocabulary and the filesystem (#11394), the shape #10907 + * put on `check-i18n-coverage.mjs` one file over. + * + * EXPORTED, unlike that gate's private `at()`, because the vocabulary is: this + * module hands its consumers a repo-relative `file`, and a consumer that then + * asks the filesystem about it has to reach the same disk this module read from. + * Identical in value to `check-i18n-coverage.mjs`'s `at()` — both files live in + * `scripts/` — and deliberately not imported from it: that gate imports THIS + * module, so taking its root would be a cycle, and the anchor is two lines. + */ +export const atRepoRoot = (rel) => join(REPO_ROOT, rel); + +// Repo-relative ON PURPOSE, and NOT to be anchored (#10907's note, restated here +// because #11394 anchored the reads around them). These spellings are the text +// every consumer's error messages and rerun commands are written in, and `CLI` is +// an argv path spawned against a cwd of the repo root. `atRepoRoot` above is the +// one seam that turns them into paths on disk, so the vocabulary stays relative +// while every READ is anchored. Making them absolute would rewrite the commands +// this repo tells a reader to run. /** The bin stub every gate spawns. A source file — its presence means nothing. */ export const CLI = 'packages/cli/bin/run.js'; /** The package whose `oclif` block declares where the built commands land. */ @@ -182,10 +208,33 @@ export function looksLikeStaleWorkspaceDist(text) { /** * `oclifCommandFileFor` against the real `packages/cli/package.json`. * - * Both failure modes come back as one `unknown` REASON rather than a guessed - * path, because a probe that cannot read the declaration must not turn a - * correctly-built workspace red: the caller prints the reason and defers to its - * in-loop signature net, which is the actual enforcement. + * The read is ANCHORED (#11394). It used to be `join(CLI_PKG, 'package.json')` + * against the cwd, so from anywhere but the repo root it ENOENTed and the probe + * deferred — not because the declaration was unreadable, but because it had been + * looked for in the wrong place. Every off-root run of either consumer was + * preceded by + * + * check-i18n-coverage: could not read packages/cli/package.json (ENOENT …) + * — build prerequisite not pre-checked + * + * over a workspace that was fine, and on an UNBUILT tree it cost the diagnosis + * outright: the one environment fact reached the in-loop net once per config + * instead of being named once, up front. + * + * The deferral DIRECTION is unchanged and still correct — both failure modes come + * back as one `unknown` REASON rather than a guessed path, because a probe that + * cannot read the declaration must not turn a correctly-built workspace red: the + * caller prints the reason and defers to its in-loop signature net, which is the + * actual enforcement. What #11394 changed is which facts can reach it. Only a + * genuinely unreadable or unshaped declaration does now; a foreign cwd does not. + * + * ⛔ Do not restore the cwd-relative read to reproduce a deferral. It was used as + * one once — #11395's twelve-way cause split was measured through it — and that + * reason discharged at `a1508766`: `groupFailuresByCause` keys a cause on a `fix` + * drawn from a CLOSED set of constants, and the per-config rerun command is + * rendered by the report, so the split cannot recur whether this probe defers or + * not. A deferral for testing is `oclifCommandFileFor({ oclif: {} }, …)`, which is + * pure and is what both consumers' `--self-test` already pins. * * @param {string[]} commandId * @returns {{ file: string } | { unknown: string }} @@ -193,7 +242,10 @@ export function looksLikeStaleWorkspaceDist(text) { export function resolveCliCommandFile(commandId) { let pkgJson; try { - pkgJson = JSON.parse(readFileSync(join(CLI_PKG, 'package.json'), 'utf8')); + // The message stays repo-relative (it is the path a reader would `cat` from + // the root); only the READ is anchored. node's own ENOENT text carries the + // absolute path it tried, which is evidence rather than vocabulary. + pkgJson = JSON.parse(readFileSync(atRepoRoot(join(CLI_PKG, 'package.json')), 'utf8')); } catch (e) { return { unknown: `could not read ${CLI_PKG}/package.json (${e.message})` }; }