Skip to content

Commit ffd5bda

Browse files
committed
fix(tooling): check:i18n-walk-parity refuses with the prerequisite code, not a finding's
`reportPrerequisiteNotMet` said "NOTHING was measured" and then returned exit 1 — the code a real finding uses. On one worktree sweep three gates refused for the same reason and returned 3, 1, 3: two are legible as NOT MEASURED from the number alone, and this one read as a false RED against whatever landed most recently. The number, not the stderr, is what a sweep, a runner script or a CI step condition reads. Route the refusal through the repo's one answer: import EXIT_PREREQUISITE_NOT_MET and EXIT_FINDINGS from `scripts/import-prerequisite.mjs` rather than re-spelling either, the way `check-dts-closure.mjs` does and the two sibling gates in this same i18n family already do. The refusal's words are unchanged byte for byte; it gains the shared "capture the code BEFORE any pipe" advisory, which names both numbers by interpolating them. Pinned by a second self-test battery that DRIVES all three verdicts through the exit code a shell actually sees — a throwaway root holding this gate, the modules it imports and whichever built inputs the case wants it to see: * the card's repro (no built inputs) -> 3, with the same headline * FIRING CONTROL: a declared group nothing emits, both sides readable -> 1, reached by measuring * NONSENSE CONTROL: nothing to find -> 0 A pin that only checked the prerequisite path would pass on a file that returned 3 for everything, which is a worse defect than the one being fixed. Structural cases pin the same thing over the function bodies — no literal exit call, no spelled-out code in the advisory — each with a negative control that must SEE a function doing the forbidden thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
1 parent 07c56b5 commit ffd5bda

1 file changed

Lines changed: 204 additions & 13 deletions

File tree

scripts/check-i18n-walk-parity.mjs

Lines changed: 204 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@
125125
* samples, exactly as its sibling does.
126126
*/
127127

128-
import { existsSync, readFileSync } from 'node:fs';
128+
import { spawnSync } from 'node:child_process';
129+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
130+
import { tmpdir } from 'node:os';
129131
import { dirname, join, resolve } from 'node:path';
130132
import { fileURLToPath, pathToFileURL } from 'node:url';
131133

@@ -136,6 +138,17 @@ import { isEntrypoint } from './invoked-as.mjs';
136138
// emitter from the walker is exactly the card that must see this gate named in
137139
// its brief, and that is the edge this import buys.
138140
import { CLI_BUILD_FIX, workspaceBuildFix } from './cli-build-prerequisite.mjs';
141+
// The exit-code contract, IMPORTED rather than re-picked. `import-prerequisite.mjs`
142+
// is where this repo answers "can this gate run at all?", and its header states the
143+
// reason in as many words: *"Exit 1 from an unmet prerequisite and exit 1 from a real
144+
// finding are the same reading — which is why the guarded refusal below does NOT keep
145+
// that number."* This gate spelled that same headline by hand and kept exit 1, so a
146+
// sweep, a runner script or a CI `if:` read its refusal as a FINDING against whatever
147+
// landed most recently — measured on one worktree where three gates refused for the
148+
// same reason and returned 3, 1, 3. ⛔ Never re-spell either number here: a literal is
149+
// what drifts, and the four sibling gates that answer these words (two of them in this
150+
// same i18n family) all take them from this one module.
151+
import { EXIT_FINDINGS, EXIT_PREREQUISITE_NOT_MET, importerCommandPath } from './import-prerequisite.mjs';
139152

140153
const HERE = dirname(fileURLToPath(import.meta.url));
141154
/** This script lives in `scripts/`, so the repo root is one level up. */
@@ -310,18 +323,46 @@ export function emptyPopulationProblems(declared, walked) {
310323

311324
// ── Reading the two sides ───────────────────────────────────────────────────
312325

313-
/** A hard prerequisite failure: says what it did NOT measure, and exits. */
326+
/**
327+
* A hard prerequisite failure: says what it did NOT measure, and exits
328+
* `EXIT_PREREQUISITE_NOT_MET` — ⛔ never a finding's `EXIT_FINDINGS`.
329+
*
330+
* Nothing here was measured, so the number must not be the one a real finding
331+
* returns. The refusal's WORDS were always right; the number was not, and the
332+
* number is what a sweep, a runner script and a CI step condition read.
333+
*/
314334
function reportPrerequisiteNotMet(headline, lines) {
315-
console.error(`❌ check:i18n-walk-parity — PREREQUISITE NOT MET: ${headline}\n`);
316-
for (const line of lines) console.error(` ${line}`);
317-
console.error(
318-
'\n NOTHING was measured. This is not "no unwalked groups" — the comparison'
335+
console.error(prerequisiteNotMetText(headline, lines));
336+
process.exit(EXIT_PREREQUISITE_NOT_MET);
337+
}
338+
339+
/**
340+
* The text `reportPrerequisiteNotMet` prints, as a value — so `--self-test` can
341+
* assert on the advisory without spawning a process or stubbing `process.exit`.
342+
* The extraction is the whole point: while the string was built inline inside
343+
* `console.error(...)` and the code was typed into the `process.exit` on the next
344+
* line, there was no VALUE to assert on and the number was pinned by nothing.
345+
* Same shape as `import-prerequisite.mjs`'s own split, and as the two sibling
346+
* gates in this family.
347+
*
348+
* ⛔ The advisory INTERPOLATES both codes rather than spelling either: a number
349+
* typed in here would keep reading right long after the constant moved.
350+
*/
351+
function prerequisiteNotMetText(headline, lines) {
352+
const command = importerCommandPath(import.meta.url);
353+
return (
354+
`❌ check:i18n-walk-parity — PREREQUISITE NOT MET: ${headline}\n`
355+
+ `\n${lines.map((line) => ` ${line}`).join('\n')}`
356+
+ '\n\n NOTHING was measured. This is not "no unwalked groups" — the comparison'
319357
+ '\n never ran. Build the workspace and run it again:'
320358
+ `\n\n ${CLI_BUILD_FIX}`
321359
+ `\n ${workspaceBuildFix('@objectstack/spec')}`
322-
+ '\n',
360+
+ `\n\n (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:`
361+
+ `\n \`node ${command} > /tmp/check-i18n-walk-parity.log 2>&1; echo "EXIT=$?"\`.`
362+
+ '\n Piped, `$?` is the LAST command\'s status, and `head`/`tail` essentially never fail — that'
363+
+ '\n is the false green, and no pipe shape repairs it.)'
364+
+ '\n'
323365
);
324-
process.exit(1);
325366
}
326367

327368
async function loadBuilt(rel, what) {
@@ -477,18 +518,18 @@ async function main(wantList) {
477518
for (const p of shape) console.error(` ${p.group}${p.why}`);
478519
for (const line of ratchet) console.error(` ${line}`);
479520
console.error('');
480-
return 1;
521+
return EXIT_FINDINGS;
481522
}
482523

483524
const { unwalked, staleUndeclared, staleWalked } = parityVerdict({ declared, walked, ledger });
484525
if (unwalked.length) {
485526
reportUnwalked(unwalked);
486527
if (staleUndeclared.length || staleWalked.length) reportStale(staleUndeclared, staleWalked);
487-
return 1;
528+
return EXIT_FINDINGS;
488529
}
489530
if (staleUndeclared.length || staleWalked.length) {
490531
reportStale(staleUndeclared, staleWalked);
491-
return 1;
532+
return EXIT_FINDINGS;
492533
}
493534

494535
console.log(
@@ -545,11 +586,12 @@ const RECORDED_UNWALKED = ['messages', 'settings', 'settingsCommon'];
545586
// to find what stopped registering.
546587
const SELF_TEST_BATTERIES = Object.freeze({
547588
'check-i18n-walk-parity self-test': 23,
589+
'the prerequisite refusal CLASS, driven end to end': 20,
548590
});
549591

550592
// DELETING an entry silences that battery's floor exactly as effectively as
551593
// zeroing it, so the roster's own size is pinned too.
552-
const SELF_TEST_BATTERY_FLOOR = 1;
594+
const SELF_TEST_BATTERY_FLOOR = 2;
553595

554596
// The key an assertion is filed under when no battery is open. It is not a
555597
// declared battery, so it reds by the same set difference rather than silently
@@ -585,6 +627,14 @@ function selfTest() {
585627
const b = JSON.stringify(want);
586628
if (a !== b) failures.push(`${what}: got ${a}, want ${b}`);
587629
};
630+
// The same sink for a case whose evidence is a STRING nobody wants compared —
631+
// a function body, or what a spawned child actually printed. `eq` would report
632+
// "got false, want true" and throw the only thing a reader could act on away.
633+
const ok = (what, held, evidence) => {
634+
registerCase();
635+
cases += 1;
636+
if (!held) failures.push(`${what}${String(evidence).slice(0, 400)}`);
637+
};
588638

589639
// 1 — parity holds when every declared group is walked.
590640
eq('clean parity', parityVerdict({ declared: ['a', 'b'], walked: ['a', 'b'], ledger: {} }),
@@ -645,6 +695,144 @@ function selfTest() {
645695
ledgerRatchetProblems(KNOWN_NO_EXTRACTOR_FACE, LEDGER_CEILING).length, 0);
646696
eq('recorded sample: hints are live', DECLARED_WATCH_HINTS.length > 0, true);
647697

698+
// ── The prerequisite refusal CLASS, and the advisory that names it ───────
699+
//
700+
// The classifier cases above decide WHICH verdict fires. They stay green
701+
// whatever number the printer beside them returns, which is exactly how this
702+
// gate shipped a refusal that said "NOTHING was measured" and then handed back
703+
// a finding's exit code. What is pinned here is the NUMBER — and, because a
704+
// file that returned the prerequisite code for everything would be a worse
705+
// defect than the one being fixed, the finding path is driven in the SAME
706+
// harness and must still answer 1.
707+
battery('the prerequisite refusal CLASS, driven end to end');
708+
709+
const advisory = prerequisiteNotMetText('the workspace spec package is not built', ['probe detail']);
710+
// Pinned over the FUNCTION BODIES, not over the constant alone. The regression
711+
// that costs something is not a mistyped constant: it is a `process.exit(1)`
712+
// written back into the refusal by an author who never thought about exit
713+
// codes, or a number typed into the advisory instead of interpolated. Either
714+
// leaves the constant reading 3, every consumer green (they all treat any
715+
// non-zero as failure) and a message that still reads perfectly right.
716+
const hardcodesExitCall = (fn) => /process\.exit\(\s*\d/.test(fn.toString());
717+
const spellsALiteralCode = (fn) => /Exit code \d/.test(fn.toString());
718+
// The NEGATIVE CONTROLS, and the reason the two predicates above are
719+
// measurements rather than tautologies: each is run against a function that
720+
// does the forbidden thing and must SEE it. ⛔ Neither control is ever CALLED;
721+
// they exist to be read by `toString()`.
722+
const controlHardcodedExit = () => { process.exit(1); };
723+
const controlLiteralAdvisory = () => ' (Exit code 1, distinct from a finding\'s 1 — capture it BEFORE any pipe:';
724+
725+
eq('refusal class: the code is the repo-wide 3', EXIT_PREREQUISITE_NOT_MET, 3);
726+
eq('refusal class: distinct from a finding AND from a pass',
727+
EXIT_PREREQUISITE_NOT_MET !== EXIT_FINDINGS && EXIT_PREREQUISITE_NOT_MET !== 0, true);
728+
ok('refusal class: the refusal exits through the named constant, never a literal',
729+
!hardcodesExitCall(reportPrerequisiteNotMet), reportPrerequisiteNotMet.toString());
730+
ok('refusal class: the printer prints the pinned text function',
731+
/console\.error\(\s*prerequisiteNotMetText\(/.test(reportPrerequisiteNotMet.toString()),
732+
reportPrerequisiteNotMet.toString());
733+
ok('refusal class: the advisory INTERPOLATES the codes rather than spelling them',
734+
!spellsALiteralCode(prerequisiteNotMetText), prerequisiteNotMetText.toString());
735+
ok('refusal class: the advisory names its own code AND the finding code it is distinct from',
736+
advisory.includes(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`) && advisory.includes(`a finding's ${EXIT_FINDINGS}`),
737+
advisory);
738+
ok('refusal class: no stale spelling of the code this refusal used to return',
739+
!/Exit code 1\b/.test(advisory), advisory);
740+
ok('refusal class: the advisory still states that NOTHING was measured',
741+
advisory.includes('NOTHING was measured'), advisory);
742+
ok('refusal class: the headline the reader already knows is byte-for-byte unchanged',
743+
advisory.startsWith('❌ check:i18n-walk-parity — PREREQUISITE NOT MET: the workspace spec package is not built\n'),
744+
advisory.slice(0, 120));
745+
ok('NEGATIVE CONTROL: the literal-exit pin can still fail',
746+
hardcodesExitCall(controlHardcodedExit), 'the predicate no longer sees a hard-coded exit');
747+
ok('NEGATIVE CONTROL: the literal-advisory pin can still fail',
748+
spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code');
749+
ok('NEGATIVE CONTROL: the stale-code pin can still fail',
750+
/Exit code 1\b/.test(controlLiteralAdvisory()), 'the stale-spelling predicate no longer sees `Exit code 1`');
751+
752+
// ── …and end to end, through the exit code a shell actually sees ─────────
753+
//
754+
// A code assertion on the constant cannot see the thing that goes wrong: the
755+
// gate is a process, and what a sweep reads is `process.exitCode`. So the
756+
// three verdicts are DRIVEN — a throwaway root holding this gate, the modules
757+
// it imports, and whichever built inputs the case wants it to see.
758+
//
759+
// ⛔ The child never spawns a child of its own: it is run in PRODUCTION mode
760+
// (no `--self-test`), so it cannot reach this battery. A hang is the worst
761+
// reading a gate can return, so the spawn is bounded by a timeout as well.
762+
const CHILD_MODULE_GRAPH = [
763+
'scripts/check-i18n-walk-parity.mjs',
764+
'scripts/invoked-as.mjs',
765+
'scripts/cli-build-prerequisite.mjs',
766+
'scripts/import-prerequisite.mjs',
767+
];
768+
// The ledger's own keys are declared into every readable fixture, so the only
769+
// finding a fixture produces is the one it was built to produce — a stale
770+
// ledger entry is a different verdict and would prove a different thing.
771+
const LEDGERED = Object.keys(KNOWN_NO_EXTRACTOR_FACE);
772+
const roots = [];
773+
const fixtureRoot = ({ declared, walked }) => {
774+
const root = mkdtempSync(join(tmpdir(), 'i18n-walk-parity-'));
775+
roots.push(root);
776+
mkdirSync(join(root, dirname(FIXTURE)), { recursive: true });
777+
for (const rel of CHILD_MODULE_GRAPH) cpSync(join(REPO_ROOT, rel), join(root, rel));
778+
// The marker `repoRootFrom` reads, so the child's advisory names a
779+
// repo-relative command rather than falling back to an absolute path.
780+
writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n");
781+
// Read and parsed by the gate, handed to a stub walker that ignores it.
782+
writeFileSync(join(root, FIXTURE), '{}\n');
783+
if (declared) {
784+
mkdirSync(join(root, dirname(SPEC_SYSTEM_DIST)), { recursive: true });
785+
writeFileSync(
786+
join(root, SPEC_SYSTEM_DIST),
787+
`export const TranslationDataSchema = { shape: ${JSON.stringify(Object.fromEntries(declared.map((g) => [g, {}])))} };\n`,
788+
);
789+
}
790+
if (walked) {
791+
mkdirSync(join(root, dirname(CLI_WALKER_DIST)), { recursive: true });
792+
// `packages/cli` is `type: module` in this repo, and a `.js` under a root
793+
// with no manifest would be read as CommonJS — the stub would be a
794+
// SyntaxError and the child would refuse for the wrong reason.
795+
writeFileSync(join(root, 'packages/cli/package.json'), '{ "name": "@objectstack/cli", "type": "module" }\n');
796+
writeFileSync(
797+
join(root, CLI_WALKER_DIST),
798+
`export function collectExpectedEntries() { return ${JSON.stringify(walked.map((g) => ({ path: [g, 'k'] })))}; }\n`,
799+
);
800+
}
801+
return root;
802+
};
803+
const drive = (root) => spawnSync(process.execPath, [join(root, 'scripts/check-i18n-walk-parity.mjs')], {
804+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000,
805+
});
806+
const said = (r) => `${r.stderr ?? ''}${r.stdout ?? ''}`;
807+
808+
try {
809+
// (a) THE CARD'S REPRO: built inputs are not there.
810+
const refused = drive(fixtureRoot({}));
811+
// (b) FIRING CONTROL: both sides readable, one declared group nothing emits.
812+
const found = drive(fixtureRoot({ declared: ['apps', 'ghost', ...LEDGERED], walked: ['apps'] }));
813+
// (c) NONSENSE CONTROL: the same harness with nothing to find.
814+
const clean = drive(fixtureRoot({ declared: ['apps', ...LEDGERED], walked: ['apps'] }));
815+
816+
eq('end to end: an unbuilt tree refuses with the PREREQUISITE code', refused.status, EXIT_PREREQUISITE_NOT_MET);
817+
ok('end to end: …in the same words it always said',
818+
said(refused).includes('PREREQUISITE NOT MET: the workspace spec package is not built'), said(refused));
819+
ok('end to end: …and states that nothing was measured',
820+
said(refused).includes('NOTHING was measured'), said(refused));
821+
822+
eq('FIRING CONTROL: a REAL finding in the same harness still exits 1', found.status, EXIT_FINDINGS);
823+
ok('FIRING CONTROL: …and it is the unwalked-group finding',
824+
said(found).includes('declared translation group(s) that the extractor does not walk')
825+
&& said(found).includes('ghost'), said(found));
826+
ok('FIRING CONTROL: …reached by MEASURING, never by refusing',
827+
!said(found).includes('PREREQUISITE NOT MET'), said(found));
828+
829+
eq('NONSENSE CONTROL: the same harness with nothing to find exits 0', clean.status, 0);
830+
ok('NONSENSE CONTROL: …and says every declared group has an extractor face',
831+
said(clean).includes('every declared group has an extractor face'), said(clean));
832+
} finally {
833+
for (const root of roots) rmSync(root, { recursive: true, force: true });
834+
}
835+
648836
// ── The floor: every declared battery RAN, and ran its cases (#13489) ────
649837
//
650838
// Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -706,7 +894,10 @@ function selfTest() {
706894
+ 'a stale entry (undeclared, or now walked) fails, the size ratchet refuses growth AND slack, '
707895
+ 'an empty side is refused, the recorded sample of today\'s real group names reproduces its verdict, '
708896
+ 'and the SHIPPED ledger is pinned to exactly that recorded unwalked set — every reason passing the '
709-
+ 'reason checks, its size exactly at the ceiling.',
897+
+ 'reason checks, its size exactly at the ceiling. Driven end to end through the exit code a shell '
898+
+ `sees: the prerequisite refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's `
899+
+ `${EXIT_FINDINGS}, with an advisory that names both numbers rather than spelling either — while a `
900+
+ `real finding in the same harness still exits ${EXIT_FINDINGS} and a clean tree still exits 0.`,
710901
);
711902
selfTestReachedVerdict = true;
712903
return 0;

0 commit comments

Comments
 (0)