Skip to content

Commit a234b7d

Browse files
claude[bot]claude
andauthored
test(scripts): pin the prerequisite exit code and advisory in the three i18n gates (#15181)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * test(cli): pin check-app-nav-i18n's refusal code and advisory from its own self-test 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6757634 commit a234b7d

3 files changed

Lines changed: 369 additions & 21 deletions

File tree

packages/cli/scripts/check-app-nav-i18n.mjs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -585,14 +585,90 @@ function selfTest() {
585585
expect('#8764 verdict names the key path', renderedPage.includes('pages.marketplace_installed.subtitle'), renderedPage);
586586
expect('#8764 verdict names the authoring package', renderedPage.includes('@objectstack/cloud-connection'), renderedPage);
587587

588+
// ── The refusal CLASS, and the advisory that must move with it (#14857) ──
589+
//
590+
// `checkBuildPrerequisite` is this gate's only refusal, and until its printer
591+
// was split into `buildPrerequisiteText` there was no VALUE to assert on: the
592+
// string was built inline inside `console.error(...)` and the code was typed
593+
// into the `process.exit` on the next line. So the number PR #14856 moved
594+
// from 1 to 3 was pinned here by nothing, while four sibling gates pin both
595+
// the code and the advisory that names it.
596+
//
597+
// ⛔ The verdict cases above are NOT this coverage: they decide WHICH finding
598+
// fires and stay green whatever number the refusal beside them returns.
599+
const refusalAdvisory = buildPrerequisiteText('/repo/packages/cli/node_modules/@objectstack/setup/dist/index.mjs');
600+
expect(
601+
'#14857 the refusal class is 3 — the code the four sibling gates answer these words with',
602+
EXIT_PREREQUISITE_NOT_MET === 3,
603+
String(EXIT_PREREQUISITE_NOT_MET),
604+
);
605+
expect(
606+
'#14857 the refusal class is distinct from a finding and from a pass',
607+
EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0,
608+
`prerequisite ${EXIT_PREREQUISITE_NOT_MET}, finding ${EXIT_FINDINGS}`,
609+
);
610+
611+
// Pinned over the FUNCTION BODIES, not over the constant alone. The
612+
// regression that costs something is not a mistyped constant: it is a
613+
// `process.exit(1)` written back into the refusal by an author who never
614+
// thought about exit codes, or a number typed into the advisory instead of
615+
// interpolated. Either leaves the constant reading 3, every consumer green
616+
// (they all treat any non-zero as failure), and a message that still reads
617+
// perfectly right.
618+
const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString());
619+
const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString());
620+
expect(
621+
'#14857 the refusal exits through the named constant, never a literal',
622+
!hardcodesExitCall(checkBuildPrerequisite),
623+
checkBuildPrerequisite.toString(),
624+
);
625+
// The seam the advisory cases depend on: a text pinned here is worthless if
626+
// the refusal stops printing THIS text.
627+
expect(
628+
'#14857 the refusal prints the pinned text function',
629+
/console\.error\(\s*buildPrerequisiteText\(/.test(checkBuildPrerequisite.toString()),
630+
checkBuildPrerequisite.toString(),
631+
);
632+
expect(
633+
'#14857 the advisory INTERPOLATES the code rather than spelling one',
634+
!spellsALiteralCode(buildPrerequisiteText),
635+
buildPrerequisiteText.toString(),
636+
);
637+
expect(
638+
'#14857 the advisory names its own code AND the finding code it is distinct from',
639+
refusalAdvisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`)
640+
&& refusalAdvisory.includes(`a finding's ${EXIT_FINDINGS}`),
641+
refusalAdvisory,
642+
);
643+
expect('#14857 the advisory carries NO stale spelling of the old code', !/Exit code 1\b/.test(refusalAdvisory), refusalAdvisory);
644+
expect('#14857 the advisory still states that nothing was measured', refusalAdvisory.includes('Nothing was measured'), refusalAdvisory);
645+
expect(
646+
'#14857 the advisory names the probe file it was handed, not a re-derived one',
647+
refusalAdvisory.includes('/repo/packages/cli/node_modules/@objectstack/setup/dist/index.mjs'),
648+
refusalAdvisory,
649+
);
650+
651+
// The NEGATIVE CONTROLS, and the reason the predicates above are
652+
// measurements rather than tautologies: each is run against a function that
653+
// does the forbidden thing and must SEE it. Without these, one typo in either
654+
// regex passes forever — a pin that cannot fail is not a pin. ⛔ Neither
655+
// control is ever CALLED; they exist to be read by `toString()`.
656+
const controlHardcodedExit = () => { process.exit(1); };
657+
const controlLiteralAdvisory = () => ` (Exit code 1, distinct from a finding's 1 — capture it BEFORE any pipe:`;
658+
expect('#14857 NEGATIVE CONTROL: the literal-exit pin can still fail', hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit');
659+
expect('#14857 NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code');
660+
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`');
661+
588662
if (failures.length) {
589663
console.error(`\ncheck-app-nav-i18n --self-test: ${failures.length} failure(s)\n`);
590664
for (const f of failures) console.error(` ${f}`);
591665
process.exit(1);
592666
}
593667
console.log(
594668
'✓ check:app-nav-i18n --self-test — the nav walk, the per-locale label verdict, the silent-contributor guard, ' +
595-
'and the `pages.*` default-locale parity verdict (drift, orphan, phantom key) all go red on the shapes they exist to catch.',
669+
'and the `pages.*` default-locale parity verdict (drift, orphan, phantom key) all go red on the shapes they exist to catch; ' +
670+
`and the build-prerequisite refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ${EXIT_FINDINGS} — ` +
671+
'with an advisory that names the number it claims (#14857).',
596672
);
597673
}
598674

@@ -632,7 +708,25 @@ if (process.argv.includes('--self-test')) {
632708
function checkBuildPrerequisite() {
633709
const probe = join(CLI_ROOT, 'node_modules', '@objectstack', 'setup', 'dist', 'index.mjs');
634710
if (existsSync(probe)) return;
635-
console.error(
711+
console.error(buildPrerequisiteText(probe));
712+
process.exit(EXIT_PREREQUISITE_NOT_MET);
713+
}
714+
715+
/**
716+
* The text `checkBuildPrerequisite` prints, as a value — so `--self-test` can
717+
* assert on the advisory (and on the code it names) without spawning a process,
718+
* stubbing `process.exit`, or unbuilding the tree. The extraction is the whole
719+
* point: while the string was built inline inside `console.error(...)`, there
720+
* was no value for a test to read, so the number this gate answers
721+
* `PREREQUISITE NOT MET` with was pinned by nothing (#14857). Same shape as
722+
* `scripts/import-prerequisite.mjs`'s `prerequisiteNotMetText`.
723+
*
724+
* Takes the probe path rather than recomputing it: the message names the file
725+
* that was actually missing, and a text function that re-derived it could name
726+
* a different one than the check refused on.
727+
*/
728+
function buildPrerequisiteText(probe) {
729+
return (
636730
`\ncheck-app-nav-i18n: PREREQUISITE NOT MET — the workspace packages are not built\n\n` +
637731
` This gate boots the real Setup composition, so it imports the BUILT output of\n` +
638732
` every contributing package. This one is not there:\n\n` +
@@ -646,9 +740,8 @@ function checkBuildPrerequisite() {
646740
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
647741
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
648742
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
649-
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
743+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`
650744
);
651-
process.exit(EXIT_PREREQUISITE_NOT_MET);
652745
}
653746

654747
checkBuildPrerequisite();

scripts/check-i18n-bundles.mjs

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
124124
'Fourth classifier (#7681): the OTHER prerequisite — a workspace package this': 24,
125125
'Fifth classifier (#11647): the POPULATION — is there anything to grade, and': 11,
126126
'The other cause, and the reason `=== 0` alone is not the whole condition: a': 8,
127+
'The prerequisite refusal CLASS, and the advisory that names it (#14857).': 14,
127128
});
128129

129130
// DELETING an entry silences that battery's floor exactly as effectively as
130131
// zeroing it, so the roster's own size is pinned too.
131-
const SELF_TEST_BATTERY_FLOOR = 4;
132+
const SELF_TEST_BATTERY_FLOOR = 5;
132133

133134
// The key an assertion is filed under when no battery is open. It is not a
134135
// declared battery, so it reds by the same set difference rather than silently
@@ -986,6 +987,98 @@ function selfTest() {
986987
const longWalkError = unreadablePopulationDetail(new Error('E'.repeat(400))).join('\n');
987988
expect('#11647 long walk errors are truncated', longWalkError.includes(`${'E'.repeat(160)}…`), 'a 400-char message must not be pasted whole');
988989

990+
// ── The refusal CLASS, and the advisory that must move with it (#14857) ──
991+
//
992+
// `reportPrerequisiteNotMet` is the only site that answers this gate's
993+
// "nothing was checked" words — five call sites, one printer — and until it
994+
// was split into a pure text function there was no VALUE to assert on: the
995+
// string was built inline inside `console.error(...)` and the code was typed
996+
// into the `process.exit` on the next line. So the number PR #14856 moved
997+
// from 1 to 3 was pinned here by nothing, while four sibling gates pin it.
998+
//
999+
// ⛔ The classifier batteries above are NOT this coverage: they decide WHICH
1000+
// verdict fires and stay green whatever number the printer beside them
1001+
// returns.
1002+
//
1003+
// Both `scanned` branches are rendered, because the in-loop net's wording is
1004+
// a second copy of the closing paragraph and could drift on its own.
1005+
battery('The prerequisite refusal CLASS, and the advisory that names it (#14857).');
1006+
1007+
const REFUSAL_ADVISORIES = [
1008+
['pre-loop probe (scanned 0)', prerequisiteNotMetText('the CLI is not built', ['probe detail'])],
1009+
['in-loop net (scanned > 0)', prerequisiteNotMetText('a workspace package is stale', ['probe detail'], { scanned: 4 })],
1010+
];
1011+
1012+
expect(
1013+
'the refusal class is 3 — the code the four sibling gates answer these words with',
1014+
EXIT_PREREQUISITE_NOT_MET === 3,
1015+
String(EXIT_PREREQUISITE_NOT_MET),
1016+
);
1017+
expect(
1018+
'the refusal class is distinct from a finding and from a pass',
1019+
EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0,
1020+
`prerequisite ${EXIT_PREREQUISITE_NOT_MET}, finding ${EXIT_FINDINGS}`,
1021+
);
1022+
1023+
// Pinned over the FUNCTION BODIES, not over the constant alone. The
1024+
// regression that costs something is not a mistyped constant: it is a
1025+
// `process.exit(1)` written back into the refusal by an author who never
1026+
// thought about exit codes, or a number typed into the advisory instead of
1027+
// interpolated. Either leaves the constant reading 3, every consumer green
1028+
// (they all treat any non-zero as failure), and a message that still reads
1029+
// perfectly right.
1030+
const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString());
1031+
const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString());
1032+
expect(
1033+
'the refusal exits through the named constant, never a literal',
1034+
!hardcodesExitCall(reportPrerequisiteNotMet),
1035+
reportPrerequisiteNotMet.toString(),
1036+
);
1037+
// The seam the advisory cases depend on: a text pinned here is worthless if
1038+
// the printer stops printing THIS text.
1039+
expect(
1040+
'the printer prints the pinned text function',
1041+
/console\.error\(\s*prerequisiteNotMetText\(/.test(reportPrerequisiteNotMet.toString()),
1042+
reportPrerequisiteNotMet.toString(),
1043+
);
1044+
expect(
1045+
'the advisory INTERPOLATES the code rather than spelling one',
1046+
!spellsALiteralCode(prerequisiteNotMetText),
1047+
prerequisiteNotMetText.toString(),
1048+
);
1049+
for (const [label, advisory] of REFUSAL_ADVISORIES) {
1050+
expect(
1051+
`${label}: the advisory names its own code AND the finding code it is distinct from`,
1052+
advisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`) && advisory.includes(`a finding's ${EXIT_FINDINGS}`),
1053+
advisory,
1054+
);
1055+
expect(
1056+
`${label}: the advisory carries NO stale spelling of the old code`,
1057+
!/Exit code 1\b/.test(advisory),
1058+
advisory,
1059+
);
1060+
expect(
1061+
`${label}: the advisory still states that nothing was checked or judged`,
1062+
/Nothing was (checked|judged)/.test(advisory),
1063+
advisory,
1064+
);
1065+
}
1066+
1067+
// The NEGATIVE CONTROLS, and the reason the predicates above are
1068+
// measurements rather than tautologies: each is run against a function that
1069+
// does the forbidden thing and must SEE it. Without these, one typo in either
1070+
// regex passes forever — a pin that cannot fail is not a pin. ⛔ Neither
1071+
// control is ever CALLED; they exist to be read by `toString()`.
1072+
const controlHardcodedExit = () => { process.exit(1); };
1073+
const controlLiteralAdvisory = () => ` (Exit code 1, distinct from a finding's 1 — capture it BEFORE any pipe:`;
1074+
expect('NEGATIVE CONTROL: the literal-exit pin can still fail', hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit');
1075+
expect('NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code');
1076+
expect(
1077+
'NEGATIVE CONTROL: the stale-code pin can still fail',
1078+
/Exit code 1\b/.test(controlLiteralAdvisory()),
1079+
'the stale-spelling predicate no longer sees `Exit code 1`',
1080+
);
1081+
9891082
// ── The floor: every declared battery RAN, and ran its cases (#13489) ────
9901083
//
9911084
// Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -1038,8 +1131,10 @@ function selfTest() {
10381131
console.log(
10391132
'✓ check:i18n --self-test — bundle-drift, undeclared-authoring-key, missing-CLI-build, ' +
10401133
'stale-workspace-dist and empty-population classifiers all go red, and stay distinct; ' +
1041-
'the population walk is CWD-independent; and the build-prerequisite closure names every ' +
1042-
'package this gate extracts plus the CLI, refusing whole rather than naming some.',
1134+
'the population walk is CWD-independent; the build-prerequisite closure names every ' +
1135+
'package this gate extracts plus the CLI, refusing whole rather than naming some; and the ' +
1136+
`refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ${EXIT_FINDINGS} — with ` +
1137+
'an advisory that names the number it claims, in both `scanned` branches (#14857).',
10431138
);
10441139

10451140
return SELF_TEST_VERDICT;
@@ -1091,6 +1186,23 @@ if (process.argv.includes('--self-test')) {
10911186
* @param {{ fix?: string, alsoFix?: string[], scanned?: number }} [options]
10921187
*/
10931188
function reportPrerequisiteNotMet(headline, detail, options = {}) {
1189+
console.error(prerequisiteNotMetText(headline, detail, options));
1190+
process.exit(EXIT_PREREQUISITE_NOT_MET);
1191+
}
1192+
1193+
/**
1194+
* The text `reportPrerequisiteNotMet` prints, as a value — so `--self-test` can
1195+
* assert on the advisory (and on the code it names) without spawning a process
1196+
* or stubbing `process.exit`. The extraction is the whole point: while the
1197+
* string was built inline inside `console.error(...)`, there was no value for a
1198+
* test to read, so the number this gate answers `PREREQUISITE NOT MET` with was
1199+
* pinned by nothing (#14857). Same shape as `import-prerequisite.mjs`'s
1200+
* `prerequisiteNotMetText` and the three sibling gates that followed it.
1201+
*
1202+
* Both `scanned` branches render here, so both are reachable from the pin — the
1203+
* in-loop net's wording is not a second, unobserved copy of the advisory.
1204+
*/
1205+
function prerequisiteNotMetText(headline, detail, options = {}) {
10941206
const { fix = CLI_BUILD_FIX, alsoFix = [], scanned = 0 } = options;
10951207
const nothingChecked =
10961208
scanned === 0
@@ -1100,7 +1212,7 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) {
11001212
` packages after it were never attempted, and the ${scanned} attempted before it read the\n` +
11011213
` same unbuilt output — an "in sync" line above is not a clean bill. So this\n` +
11021214
` result says NOTHING about whether the committed translation bundles are in sync.`;
1103-
console.error(
1215+
return (
11041216
`\ncheck-i18n-bundles: PREREQUISITE NOT MET — ${headline}\n\n` +
11051217
detail.map((l) => (l ? ` ${l}` : '')).join('\n') +
11061218
`\n\n Fix: ${fix}\n` +
@@ -1112,9 +1224,8 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) {
11121224
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
11131225
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
11141226
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
1115-
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
1227+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`
11161228
);
1117-
process.exit(EXIT_PREREQUISITE_NOT_MET);
11181229
}
11191230

11201231
/**

0 commit comments

Comments
 (0)