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' ;
129131import { dirname , join , resolve } from 'node:path' ;
130132import { 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.
138140import { 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
140153const 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+ */
314334function 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
327368async 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.
546587const 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 ) => / p r o c e s s \. e x i t \( \s * \d / . test ( fn . toString ( ) ) ;
717+ const spellsALiteralCode = ( fn ) => / E x i t c o d e \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+ / c o n s o l e \. e r r o r \( \s * p r e r e q u i s i t e N o t M e t T e x t \( / . 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+ ! / E x i t c o d e 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+ / E x i t c o d e 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