From 7363d2130f84453fd98ef3b7e1370c7c7a78b070 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:07:40 +0000 Subject: [PATCH 1/4] refactor(scripts): extract check-i18n-coverage's three refusal printers into pure text functions The three PREREQUISITE NOT MET / COULD NOT MEASURE / POPULATION EMPTY printers built their string inline inside `console.error(...)`, so the exit code each path returns and the advisory that names it had no value a test could read. Same shape `scripts/import-prerequisite.mjs` already uses. Text unchanged: only the wrapper and the trailing comma move. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/check-i18n-coverage.mjs | 57 +++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 09bead52b4..8f0aa54eb2 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -1257,7 +1257,21 @@ if (process.argv.includes('--self-test')) { * the same defect, one step later, as the diagnosis it replaces. */ function reportPrerequisiteNotMet(headline, detail) { - console.error( + console.error(prerequisiteNotMetText(headline, detail)); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + +/** + * The text `reportPrerequisiteNotMet` prints, as a value — so `--self-test` can + * assert on the advisory (and on the code it names) without spawning a process + * or stubbing `process.exit`. The extraction is the whole point: while the + * string was built inline inside `console.error(...)`, there was no value for a + * test to read, so the number this gate answers `PREREQUISITE NOT MET` with was + * pinned by nothing (#14857). Same shape as `import-prerequisite.mjs`'s + * `prerequisiteNotMetText` and the three sibling gates that followed it. + */ +function prerequisiteNotMetText(headline, detail) { + return ( `\ncheck-i18n-coverage: PREREQUISITE NOT MET — ${headline}\n\n` + detail.map((l) => (l ? ` ${l}` : '')).join('\n') + `\n\n Fix: ${WORKSPACE_BUILD_FIX}\n\n` + @@ -1279,9 +1293,8 @@ function reportPrerequisiteNotMet(headline, detail) { ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + - ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(EXIT_PREREQUISITE_NOT_MET); } /** @@ -1305,6 +1318,20 @@ function reportPrerequisiteNotMet(headline, detail) { * what that code means everywhere else in this repo. */ function reportUnmeasuredConfigs(failures, measuredCount) { + console.error(unmeasuredConfigsText(failures, measuredCount)); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + +/** + * The text `reportUnmeasuredConfigs` prints, as a value — so `--self-test` can + * assert on the advisory (and on the code it names) without spawning a process + * or stubbing `process.exit`. The extraction is the whole point: while the + * string was built inline inside `console.error(...)`, there was no value for a + * test to read, so the number this gate answers `PREREQUISITE NOT MET` with was + * pinned by nothing (#14857). Same shape as `import-prerequisite.mjs`'s + * `prerequisiteNotMetText` and the three sibling gates that followed it. + */ +function unmeasuredConfigsText(failures, measuredCount) { const groups = groupFailuresByCause(failures); const total = failures.length + measuredCount; const blocks = groups.map((g, i) => @@ -1324,7 +1351,7 @@ function reportUnmeasuredConfigs(failures, measuredCount) { .map((l) => (l ? ` ${l}` : '')) .join('\n'), ); - console.error( + return ( `\ncheck-i18n-coverage: COULD NOT MEASURE — ${failures.length} of ${total} config(s) failed to lint ` + `(${groups.length} distinct cause${groups.length === 1 ? '' : 's'})\n\n` + blocks.join('\n\n') + @@ -1342,9 +1369,8 @@ function reportUnmeasuredConfigs(failures, measuredCount) { ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + - ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(EXIT_PREREQUISITE_NOT_MET); } /** @@ -1364,7 +1390,21 @@ function reportUnmeasuredConfigs(failures, measuredCount) { * @param {{ headline: string, detail: string[] }} verdict */ function reportEmptyPopulation(verdict) { - console.error( + console.error(emptyPopulationText(verdict)); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + +/** + * The text `reportEmptyPopulation` prints, as a value — so `--self-test` can + * assert on the advisory (and on the code it names) without spawning a process + * or stubbing `process.exit`. The extraction is the whole point: while the + * string was built inline inside `console.error(...)`, there was no value for a + * test to read, so the number this gate answers `PREREQUISITE NOT MET` with was + * pinned by nothing (#14857). Same shape as `import-prerequisite.mjs`'s + * `prerequisiteNotMetText` and the three sibling gates that followed it. + */ +function emptyPopulationText(verdict) { + return ( `\ncheck-i18n-coverage: POPULATION EMPTY — ${verdict.headline}\n\n` + verdict.detail.map((l) => (l ? ` ${l}` : '')).join('\n') + `\n\n Fix: run this gate from a complete checkout of the repo. \`pnpm check:i18n-coverage\`\n` + @@ -1378,9 +1418,8 @@ function reportEmptyPopulation(verdict) { ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + - ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(EXIT_PREREQUISITE_NOT_MET); } /** From 6f447bc0038af700311d1fe7ca1b706602346e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:09:17 +0000 Subject: [PATCH 2/4] test(scripts): pin check-i18n-coverage's three refusal paths from its own self-test New battery, 23 cases, registered through the existing `registerCase()` helper: the prerequisite class is 3, distinct from a finding's 1 and from 0; each of the three printers exits through the named constant rather than a literal, prints the pinned text function, interpolates the code rather than spelling one, and renders an advisory that names both numbers with no stale `Exit code 1`. Three negative controls prove each predicate can still fail. Roster: 4 batteries -> 5 (SELF_TEST_BATTERY_FLOOR 4 -> 5); the new battery is floored at its measured 23. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/check-i18n-coverage.mjs | 109 +++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 8f0aa54eb2..7b890b3703 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -204,11 +204,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '#11395 — the same rule, pinned over EVERY failure branch instead of one.': 23, 'Root anchoring and the population classifier (#10907). These are the only': 4, 'The build-prerequisite CLOSURE (#12564). What makes the remedy worth naming': 15, + 'The prerequisite refusal CLASS, and the advisory that names it (#14857).': 23, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; +const SELF_TEST_BATTERY_FLOOR = 5; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1155,6 +1156,109 @@ function selfTest() { `the real tree resolved to ${onRoot.length} config(s) and must be judged, not refused`, ); + // ── The refusal CLASS, and the advisory that must move with it (#14857) ── + // + // The three printers below `reportPrerequisiteNotMet` are the only sites that + // answer this gate's "nothing was measured" words, and until they were split + // into pure text functions there was no VALUE to assert on: each string was + // built inline inside `console.error(...)` and the code was typed into the + // `process.exit` on the next line. So the number PR #14856 moved from 1 to 3 + // was pinned here by nothing, while four sibling gates pin exactly this. + // + // ⛔ The classifier batteries above are NOT this coverage and never were — + // they decide WHICH verdict fires and stay green whatever number the printer + // beside them returns, which is precisely why the success line used to read + // like the refusal was covered. + // + // ⭐ Two of the three paths — COULD NOT MEASURE and POPULATION EMPTY — sit + // behind the CLI-build probe, so no local run and no other test observes them + // at all. For those two these cases are the ONLY observer. + battery('The prerequisite refusal CLASS, and the advisory that names it (#14857).'); + + // Rendered from the same functions the refusals print, so the assertions + // below read the shipped text rather than a copy of it. + const REFUSAL_SITES = [ + ['PREREQUISITE NOT MET', reportPrerequisiteNotMet, prerequisiteNotMetText, + prerequisiteNotMetText('the CLI is not built', ['probe detail'])], + ['COULD NOT MEASURE', reportUnmeasuredConfigs, unmeasuredConfigsText, + unmeasuredConfigsText( + [{ configPath: 'examples/app-crm/objectstack.config.ts', reason: 'lint blew up', fix: 'build first', evidence: 'saw this' }], + 2, + )], + ['POPULATION EMPTY', reportEmptyPopulation, emptyPopulationText, + emptyPopulationText({ headline: 'no config resolved', detail: ['population detail'] })], + ]; + + expect( + 'the refusal class is 3 — the code the four sibling gates answer these words with', + EXIT_PREREQUISITE_NOT_MET === 3, + String(EXIT_PREREQUISITE_NOT_MET), + ); + expect( + 'the refusal class is distinct from a finding and from a pass', + EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0, + `prerequisite ${EXIT_PREREQUISITE_NOT_MET}, finding ${EXIT_FINDINGS}`, + ); + + // Pinned over the FUNCTION BODIES, not over the constant alone. The + // regression that costs something is not a mistyped constant: it is a + // `process.exit(1)` written back into a refusal by an author who never + // thought about exit codes, or a number typed into the advisory instead of + // interpolated. Either leaves the constant reading 3, every consumer green + // (they all treat any non-zero as failure), and a message that still reads + // perfectly right. + const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString()); + const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString()); + for (const [label, report, text, advisory] of REFUSAL_SITES) { + expect( + `${label}: the refusal exits through the named constant, never a literal`, + !hardcodesExitCall(report), + report.toString(), + ); + // The seam the two cases either side of it depend on: an advisory pinned + // here is worthless if the printer stops printing THIS text. + expect( + `${label}: the printer prints the pinned text function`, + new RegExp(`console\\.error\\(\\s*${text.name}\\(`).test(report.toString()), + report.toString(), + ); + expect( + `${label}: the advisory INTERPOLATES the code rather than spelling one`, + !spellsALiteralCode(text), + text.toString(), + ); + expect( + `${label}: the advisory names its own code AND the finding code it is distinct from`, + advisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`) && advisory.includes(`a finding's ${EXIT_FINDINGS}`), + advisory, + ); + expect( + `${label}: the advisory carries NO stale spelling of the old code`, + !/Exit code 1\b/.test(advisory), + advisory, + ); + expect( + `${label}: the advisory still states that nothing was measured`, + /Nothing was (measured|compared)/.test(advisory), + advisory, + ); + } + + // The NEGATIVE CONTROLS, and the reason the two predicates above are + // measurements rather than tautologies: each is run against a function that + // does the forbidden thing and must SEE it. Without these, one typo in either + // regex passes forever — a pin that cannot fail is not a pin. ⛔ Neither + // control is ever CALLED; they exist to be read by `toString()`. + const controlHardcodedExit = () => { process.exit(1); }; + const controlLiteralAdvisory = () => ` (Exit code 1, distinct from a finding's 1 — capture it BEFORE any pipe:`; + expect('NEGATIVE CONTROL: the literal-exit pin can still fail', hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit'); + expect('NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code'); + expect( + 'NEGATIVE CONTROL: the stale-code pin can still fail', + /Exit code 1\b/.test(controlLiteralAdvisory()), + 'the stale-spelling predicate no longer sees `Exit code 1`', + ); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -1210,7 +1314,8 @@ function selfTest() { `one shared cause ONCE, with no config path in the key; the population resolves to ${onRoot.length} config(s) ` + `from outside the repo root as well as inside it, and an empty one is refused rather than reported OK; ` + `the build-prerequisite closure names all ${onRoot.length} of them plus the CLI, and refuses whole rather ` + - `than naming some.`, + `than naming some; and all three refusal paths exit ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ` + + `${EXIT_FINDINGS} — with an advisory that names the number it claims (#14857).`, ); return SELF_TEST_VERDICT; From 3fbfdba08ed64ea280b5d37158a04ca6b4a73c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:10:55 +0000 Subject: [PATCH 3/4] test(scripts): pin check-i18n-bundles' refusal code and advisory from its own self-test Extracts the refusal printer into `prerequisiteNotMetText` (message text unchanged, both `scanned` branches move with it) and adds a 14-case battery: the prerequisite class is 3, distinct from a finding's 1 and from 0; the printer exits through the named constant, prints the pinned text function and interpolates the code; both rendered advisories name both numbers with no stale `Exit code 1`. Three negative controls prove each predicate can still fail. Roster: 4 batteries -> 5 (SELF_TEST_BATTERY_FLOOR 4 -> 5); new battery floored at its measured 14. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/check-i18n-bundles.mjs | 123 +++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 6 deletions(-) diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index 3bf3decb06..979eba74be 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -124,11 +124,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'Fourth classifier (#7681): the OTHER prerequisite — a workspace package this': 24, 'Fifth classifier (#11647): the POPULATION — is there anything to grade, and': 11, 'The other cause, and the reason `=== 0` alone is not the whole condition: a': 8, + 'The prerequisite refusal CLASS, and the advisory that names it (#14857).': 14, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; +const SELF_TEST_BATTERY_FLOOR = 5; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -986,6 +987,98 @@ function selfTest() { const longWalkError = unreadablePopulationDetail(new Error('E'.repeat(400))).join('\n'); expect('#11647 long walk errors are truncated', longWalkError.includes(`${'E'.repeat(160)}…`), 'a 400-char message must not be pasted whole'); + // ── The refusal CLASS, and the advisory that must move with it (#14857) ── + // + // `reportPrerequisiteNotMet` is the only site that answers this gate's + // "nothing was checked" words — five call sites, one printer — and until it + // was split into a pure text function there was no VALUE to assert on: the + // string was built inline inside `console.error(...)` and the code was typed + // into the `process.exit` on the next line. So the number PR #14856 moved + // from 1 to 3 was pinned here by nothing, while four sibling gates pin it. + // + // ⛔ The classifier batteries above are NOT this coverage: they decide WHICH + // verdict fires and stay green whatever number the printer beside them + // returns. + // + // Both `scanned` branches are rendered, because the in-loop net's wording is + // a second copy of the closing paragraph and could drift on its own. + battery('The prerequisite refusal CLASS, and the advisory that names it (#14857).'); + + const REFUSAL_ADVISORIES = [ + ['pre-loop probe (scanned 0)', prerequisiteNotMetText('the CLI is not built', ['probe detail'])], + ['in-loop net (scanned > 0)', prerequisiteNotMetText('a workspace package is stale', ['probe detail'], { scanned: 4 })], + ]; + + expect( + 'the refusal class is 3 — the code the four sibling gates answer these words with', + EXIT_PREREQUISITE_NOT_MET === 3, + String(EXIT_PREREQUISITE_NOT_MET), + ); + expect( + 'the refusal class is distinct from a finding and from a pass', + EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0, + `prerequisite ${EXIT_PREREQUISITE_NOT_MET}, finding ${EXIT_FINDINGS}`, + ); + + // Pinned over the FUNCTION BODIES, not over the constant alone. The + // regression that costs something is not a mistyped constant: it is a + // `process.exit(1)` written back into the refusal by an author who never + // thought about exit codes, or a number typed into the advisory instead of + // interpolated. Either leaves the constant reading 3, every consumer green + // (they all treat any non-zero as failure), and a message that still reads + // perfectly right. + const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString()); + const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString()); + expect( + 'the refusal exits through the named constant, never a literal', + !hardcodesExitCall(reportPrerequisiteNotMet), + reportPrerequisiteNotMet.toString(), + ); + // The seam the advisory cases depend on: a text pinned here is worthless if + // the printer stops printing THIS text. + expect( + 'the printer prints the pinned text function', + /console\.error\(\s*prerequisiteNotMetText\(/.test(reportPrerequisiteNotMet.toString()), + reportPrerequisiteNotMet.toString(), + ); + expect( + 'the advisory INTERPOLATES the code rather than spelling one', + !spellsALiteralCode(prerequisiteNotMetText), + prerequisiteNotMetText.toString(), + ); + for (const [label, advisory] of REFUSAL_ADVISORIES) { + expect( + `${label}: the advisory names its own code AND the finding code it is distinct from`, + advisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`) && advisory.includes(`a finding's ${EXIT_FINDINGS}`), + advisory, + ); + expect( + `${label}: the advisory carries NO stale spelling of the old code`, + !/Exit code 1\b/.test(advisory), + advisory, + ); + expect( + `${label}: the advisory still states that nothing was checked or judged`, + /Nothing was (checked|judged)/.test(advisory), + advisory, + ); + } + + // The NEGATIVE CONTROLS, and the reason the predicates above are + // measurements rather than tautologies: each is run against a function that + // does the forbidden thing and must SEE it. Without these, one typo in either + // regex passes forever — a pin that cannot fail is not a pin. ⛔ Neither + // control is ever CALLED; they exist to be read by `toString()`. + const controlHardcodedExit = () => { process.exit(1); }; + const controlLiteralAdvisory = () => ` (Exit code 1, distinct from a finding's 1 — capture it BEFORE any pipe:`; + expect('NEGATIVE CONTROL: the literal-exit pin can still fail', hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit'); + expect('NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code'); + expect( + 'NEGATIVE CONTROL: the stale-code pin can still fail', + /Exit code 1\b/.test(controlLiteralAdvisory()), + 'the stale-spelling predicate no longer sees `Exit code 1`', + ); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -1038,8 +1131,10 @@ function selfTest() { console.log( '✓ check:i18n --self-test — bundle-drift, undeclared-authoring-key, missing-CLI-build, ' + 'stale-workspace-dist and empty-population classifiers all go red, and stay distinct; ' + - 'the population walk is CWD-independent; and the build-prerequisite closure names every ' + - 'package this gate extracts plus the CLI, refusing whole rather than naming some.', + 'the population walk is CWD-independent; the build-prerequisite closure names every ' + + 'package this gate extracts plus the CLI, refusing whole rather than naming some; and the ' + + `refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ${EXIT_FINDINGS} — with ` + + 'an advisory that names the number it claims, in both `scanned` branches (#14857).', ); return SELF_TEST_VERDICT; @@ -1091,6 +1186,23 @@ if (process.argv.includes('--self-test')) { * @param {{ fix?: string, alsoFix?: string[], scanned?: number }} [options] */ function reportPrerequisiteNotMet(headline, detail, options = {}) { + console.error(prerequisiteNotMetText(headline, detail, options)); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + +/** + * The text `reportPrerequisiteNotMet` prints, as a value — so `--self-test` can + * assert on the advisory (and on the code it names) without spawning a process + * or stubbing `process.exit`. The extraction is the whole point: while the + * string was built inline inside `console.error(...)`, there was no value for a + * test to read, so the number this gate answers `PREREQUISITE NOT MET` with was + * pinned by nothing (#14857). Same shape as `import-prerequisite.mjs`'s + * `prerequisiteNotMetText` and the three sibling gates that followed it. + * + * Both `scanned` branches render here, so both are reachable from the pin — the + * in-loop net's wording is not a second, unobserved copy of the advisory. + */ +function prerequisiteNotMetText(headline, detail, options = {}) { const { fix = CLI_BUILD_FIX, alsoFix = [], scanned = 0 } = options; const nothingChecked = scanned === 0 @@ -1100,7 +1212,7 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) { ` packages after it were never attempted, and the ${scanned} attempted before it read the\n` + ` same unbuilt output — an "in sync" line above is not a clean bill. So this\n` + ` result says NOTHING about whether the committed translation bundles are in sync.`; - console.error( + return ( `\ncheck-i18n-bundles: PREREQUISITE NOT MET — ${headline}\n\n` + detail.map((l) => (l ? ` ${l}` : '')).join('\n') + `\n\n Fix: ${fix}\n` + @@ -1112,9 +1224,8 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) { ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + - ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(EXIT_PREREQUISITE_NOT_MET); } /** From 2745a17160415e1a6ffe097d71443d995b69bc1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:11:58 +0000 Subject: [PATCH 4/4] test(cli): pin check-app-nav-i18n's refusal code and advisory from its own self-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the refusal printer into `buildPrerequisiteText(probe)` (message text unchanged — the live refusal on an unbuilt tree is byte-identical before and after, still exit 3) and adds twelve cases in the file's existing self-test shape: the prerequisite class is 3, distinct from a finding's 1 and from 0; the refusal exits through the named constant, prints the pinned text function and interpolates the code; the advisory names both numbers with no stale `Exit code 1` and still names the probe file it was handed. Three negative controls prove each predicate can still fail. No battery roster added here — that is #13799's surface, not this card's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- packages/cli/scripts/check-app-nav-i18n.mjs | 101 +++++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/packages/cli/scripts/check-app-nav-i18n.mjs b/packages/cli/scripts/check-app-nav-i18n.mjs index 00578ef694..eb2fc3e31e 100644 --- a/packages/cli/scripts/check-app-nav-i18n.mjs +++ b/packages/cli/scripts/check-app-nav-i18n.mjs @@ -585,6 +585,80 @@ function selfTest() { expect('#8764 verdict names the key path', renderedPage.includes('pages.marketplace_installed.subtitle'), renderedPage); expect('#8764 verdict names the authoring package', renderedPage.includes('@objectstack/cloud-connection'), renderedPage); + // ── The refusal CLASS, and the advisory that must move with it (#14857) ── + // + // `checkBuildPrerequisite` is this gate's only refusal, and until its printer + // was split into `buildPrerequisiteText` there was no VALUE to assert on: the + // string was built inline inside `console.error(...)` and the code was typed + // into the `process.exit` on the next line. So the number PR #14856 moved + // from 1 to 3 was pinned here by nothing, while four sibling gates pin both + // the code and the advisory that names it. + // + // ⛔ The verdict cases above are NOT this coverage: they decide WHICH finding + // fires and stay green whatever number the refusal beside them returns. + const refusalAdvisory = buildPrerequisiteText('/repo/packages/cli/node_modules/@objectstack/setup/dist/index.mjs'); + expect( + '#14857 the refusal class is 3 — the code the four sibling gates answer these words with', + EXIT_PREREQUISITE_NOT_MET === 3, + String(EXIT_PREREQUISITE_NOT_MET), + ); + expect( + '#14857 the refusal class is distinct from a finding and from a pass', + EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0, + `prerequisite ${EXIT_PREREQUISITE_NOT_MET}, finding ${EXIT_FINDINGS}`, + ); + + // Pinned over the FUNCTION BODIES, not over the constant alone. The + // regression that costs something is not a mistyped constant: it is a + // `process.exit(1)` written back into the refusal by an author who never + // thought about exit codes, or a number typed into the advisory instead of + // interpolated. Either leaves the constant reading 3, every consumer green + // (they all treat any non-zero as failure), and a message that still reads + // perfectly right. + const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString()); + const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString()); + expect( + '#14857 the refusal exits through the named constant, never a literal', + !hardcodesExitCall(checkBuildPrerequisite), + checkBuildPrerequisite.toString(), + ); + // The seam the advisory cases depend on: a text pinned here is worthless if + // the refusal stops printing THIS text. + expect( + '#14857 the refusal prints the pinned text function', + /console\.error\(\s*buildPrerequisiteText\(/.test(checkBuildPrerequisite.toString()), + checkBuildPrerequisite.toString(), + ); + expect( + '#14857 the advisory INTERPOLATES the code rather than spelling one', + !spellsALiteralCode(buildPrerequisiteText), + buildPrerequisiteText.toString(), + ); + expect( + '#14857 the advisory names its own code AND the finding code it is distinct from', + refusalAdvisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`) + && refusalAdvisory.includes(`a finding's ${EXIT_FINDINGS}`), + refusalAdvisory, + ); + expect('#14857 the advisory carries NO stale spelling of the old code', !/Exit code 1\b/.test(refusalAdvisory), refusalAdvisory); + expect('#14857 the advisory still states that nothing was measured', refusalAdvisory.includes('Nothing was measured'), refusalAdvisory); + expect( + '#14857 the advisory names the probe file it was handed, not a re-derived one', + refusalAdvisory.includes('/repo/packages/cli/node_modules/@objectstack/setup/dist/index.mjs'), + refusalAdvisory, + ); + + // The NEGATIVE CONTROLS, and the reason the predicates above are + // measurements rather than tautologies: each is run against a function that + // does the forbidden thing and must SEE it. Without these, one typo in either + // regex passes forever — a pin that cannot fail is not a pin. ⛔ Neither + // control is ever CALLED; they exist to be read by `toString()`. + const controlHardcodedExit = () => { process.exit(1); }; + const controlLiteralAdvisory = () => ` (Exit code 1, distinct from a finding's 1 — capture it BEFORE any pipe:`; + expect('#14857 NEGATIVE CONTROL: the literal-exit pin can still fail', hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit'); + expect('#14857 NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code'); + expect('#14857 NEGATIVE CONTROL: the stale-code pin can still fail', /Exit code 1\b/.test(controlLiteralAdvisory()), 'the stale-spelling predicate no longer sees `Exit code 1`'); + if (failures.length) { console.error(`\ncheck-app-nav-i18n --self-test: ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); @@ -592,7 +666,9 @@ function selfTest() { } console.log( '✓ check:app-nav-i18n --self-test — the nav walk, the per-locale label verdict, the silent-contributor guard, ' + - 'and the `pages.*` default-locale parity verdict (drift, orphan, phantom key) all go red on the shapes they exist to catch.', + 'and the `pages.*` default-locale parity verdict (drift, orphan, phantom key) all go red on the shapes they exist to catch; ' + + `and the build-prerequisite refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ${EXIT_FINDINGS} — ` + + 'with an advisory that names the number it claims (#14857).', ); } @@ -632,7 +708,25 @@ if (process.argv.includes('--self-test')) { function checkBuildPrerequisite() { const probe = join(CLI_ROOT, 'node_modules', '@objectstack', 'setup', 'dist', 'index.mjs'); if (existsSync(probe)) return; - console.error( + console.error(buildPrerequisiteText(probe)); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + +/** + * The text `checkBuildPrerequisite` prints, as a value — so `--self-test` can + * assert on the advisory (and on the code it names) without spawning a process, + * stubbing `process.exit`, or unbuilding the tree. The extraction is the whole + * point: while the string was built inline inside `console.error(...)`, there + * was no value for a test to read, so the number this gate answers + * `PREREQUISITE NOT MET` with was pinned by nothing (#14857). Same shape as + * `scripts/import-prerequisite.mjs`'s `prerequisiteNotMetText`. + * + * Takes the probe path rather than recomputing it: the message names the file + * that was actually missing, and a text function that re-derived it could name + * a different one than the check refused on. + */ +function buildPrerequisiteText(probe) { + return ( `\ncheck-app-nav-i18n: PREREQUISITE NOT MET — the workspace packages are not built\n\n` + ` This gate boots the real Setup composition, so it imports the BUILT output of\n` + ` every contributing package. This one is not there:\n\n` + @@ -646,9 +740,8 @@ function checkBuildPrerequisite() { ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + - ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(EXIT_PREREQUISITE_NOT_MET); } checkBuildPrerequisite();