Skip to content

Commit 7956a7c

Browse files
committed
fix(cli): i18n extract --check --json compares instead of exiting 0 (#16600)
The machine face returned before anything was compared: `if (flags.json) { … return; }` sat ahead of both the `--check` needs-`--out` guard and the comparison block, so `--check --json` exited 0 on a tree whose bundles had provably drifted. Driven on one fixture, two invocations differing only by `--json`: the first exited 1 reporting `missing:` and the drift sentence, the second exited 0 with the ordinary payload. Same shape as the `--dry-run` branch in #16480. Under `--json`, `--check` is a verdict mode, so the comparison now runs before the one document the run is allowed to write. Drift leaves through this command's existing `{ error, …errorCodeFields }` envelope with exit 1 — no new payload member — and the needs-`--out` refusal is reachable there too. An in-sync tree and a `--json` run that did not ask for `--check` are unchanged. The file list and the comparison are now one closure each (`emittedFiles`, `compareCommitted`, `driftMessage`), read by both faces, so the console and machine `--check` cannot diverge about what this run produces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
1 parent 0a61db1 commit 7956a7c

3 files changed

Lines changed: 501 additions & 40 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os i18n extract --check --json` now COMPARES. It used to exit 0 having compared nothing, on a tree whose bundles had provably drifted.
6+
7+
The machine face returned before the comparison ran: `if (flags.json) { … return; }` sat ahead of both the `--check` needs-`--out` guard and the comparison block. Driven on one fixture, two invocations differing only by `--json` — the first exited 1 with `missing: OUT/zh-CN.objects.generated.ts` and `Translation bundles have drifted from the schema`, the second exited 0 with the ordinary extract payload. The first run is the second one's positive control: the drift was really there. Same shape as the `--dry-run` branch repaired one release earlier, and `--json` is if anything the more likely CI spelling of the two, because a pipeline that wants to parse the result reaches for it.
8+
9+
⚠️ **A pipeline that runs `os i18n extract … --check --json` and was green may now go red, and that is this repair working.** The green was a comparison that never happened; the red is the drift that was already in the tree. The fix is the one the failure names — re-run the same command without `--check` and commit what it writes.
10+
11+
What each invocation now does, with no new member on any published payload:
12+
13+
- **drift found** — the run ends on this command's existing `{ "error": … }` envelope with exit 1, carrying the same sentence the console face prints, the regenerate-and-commit command included. Deliberately not a new `drift` / `missing` / `stale` payload member: every other way this command can fail already speaks that envelope, and naming the drifted files in the machine payload would widen a published output face.
14+
- **in sync** — unchanged: the ordinary extract payload, exit 0.
15+
- **`--check` with no `--out`** — the refusal is now reachable under `--json` too, in the same `{ "error": … }` envelope with exit 1. It used to exit 0 with a payload, having been asked for a comparison it could not make.
16+
- **`--json` without `--check`** — unchanged in every respect.
17+
18+
The run leaves through exactly one of those faces, so stdout still parses as exactly one JSON document.

packages/cli/src/commands/i18n/extract.ts

Lines changed: 137 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ import {
3131

3232
const FILL_STRATEGIES: FillStrategy[] = ['empty', 'default', 'todo'];
3333

34+
/**
35+
* The refusal `--check` without `--out` ends on — one string, because two faces
36+
* now reach it. The console run throws it below the skeleton summary; a
37+
* `--json` run throws it from the machine face, where it lands in this
38+
* command's ordinary `{ error }` envelope (#16600).
39+
*/
40+
const CHECK_NEEDS_OUT =
41+
'--check needs --out=<dir> — it compares a fresh extract against the bundles committed there.';
42+
3443
/**
3544
* A path for one of this command's output lines: relative to the cwd while that
3645
* is still a NAME for the file, absolute once it stops being one.
@@ -431,7 +440,126 @@ export default class I18nExtract extends Command {
431440
return narrowToCommittedSections(table, committed);
432441
};
433442

443+
/**
444+
* Every file a normal run would write into `dir`, paired with its
445+
* rendered content — the ONE list every face that names this run's files
446+
* reads: the write loop, the console `--check`, and the `--json`
447+
* `--check` below. So no two of them can disagree about what this run
448+
* produces, and in particular `--check` can never compare something the
449+
* write path would not have written.
450+
*
451+
* It was a straight-line `const emitted` built after the `--dry-run`
452+
* branch, which is below the machine face and therefore out of its reach.
453+
* A `--json --check` run needs the same list, so the list moved rather
454+
* than being rebuilt beside it (#16600).
455+
*/
456+
const emittedFiles = (dir: string): Array<{ file: string; content: string; keys: number }> => {
457+
const files: Array<{ file: string; content: string; keys: number }> = [];
458+
for (const locale of localesEmitted) {
459+
for (const mod of emittedModules(locale)) {
460+
files.push({
461+
file: path.join(dir, `${locale}.${mod.suffix}`),
462+
content: renderTranslationModule(result.bundles[locale], { locale, kind: mod.kind }),
463+
keys: mod.keys,
464+
});
465+
}
466+
// The provenance companion rides in the SAME list, so `--check` compares
467+
// it by the same byte-for-byte rule as the bundles it belongs to and can
468+
// never diverge from what a real extract writes.
469+
const table = committedSourceHashes(locale);
470+
if (flags['source-hashes'] && table) {
471+
files.push({
472+
file: path.join(dir, `${locale}.source-hashes.generated.ts`),
473+
content: renderSourceHashModule(table, { locale }),
474+
keys: Object.keys(table).length,
475+
});
476+
}
477+
}
478+
return files;
479+
};
480+
481+
/** What `--check` found: committed files that are absent, and ones whose bytes differ. */
482+
const compareCommitted = (
483+
files: ReadonlyArray<{ file: string; content: string }>,
484+
): { missing: string[]; stale: string[] } => {
485+
const missing: string[] = [];
486+
const stale: string[] = [];
487+
for (const { file, content } of files) {
488+
const shown = displayPath(file);
489+
if (!fs.existsSync(file)) missing.push(shown);
490+
else if (fs.readFileSync(file, 'utf8') !== content) stale.push(shown);
491+
}
492+
return { missing, stale };
493+
};
494+
495+
/**
496+
* The sentence a drifted `--check` ends on, built once so both faces end
497+
* on the same words. {@link rerunWithoutCheck} says why the command it
498+
* names is spelled as a deletion and what the degraded line is for.
499+
*/
500+
const driftMessage = (): string => {
501+
const rerun = rerunWithoutCheck(this.config.bin, this.id, this.argv);
502+
return (
503+
'Translation bundles have drifted from the schema. Regenerate and commit:\n' +
504+
(rerun ? ` ${rerun}` : ' re-run the same command without `--check`')
505+
);
506+
};
507+
434508
if (flags.json) {
509+
/**
510+
* ⭐ `--check` is a VERDICT mode, so under `--json` the comparison runs
511+
* HERE — before the one document this run is allowed to write (#16600).
512+
*
513+
* ## What was wrong
514+
*
515+
* This branch emitted and returned unconditionally, which put it ahead
516+
* of both the `--check` needs-`--out` guard and the comparison itself.
517+
* Driven on one drifted fixture, the two invocations differing ONLY by
518+
* `--json`:
519+
*
520+
* $ os i18n extract CONFIG --locales=zh-CN --no-metadata-forms
521+
* --out=OUT --check
522+
* missing: OUT/zh-CN.objects.generated.ts
523+
* Translation bundles have drifted from the schema. …
524+
* -> exit 1
525+
*
526+
* $ … --out=OUT --check --json
527+
* {"totalExpected":…,"counts":…,"bundles":…}
528+
* -> exit 0, nothing compared
529+
*
530+
* The first run is the second one's positive control: the drift is
531+
* provably there and the second reported success. Same shape as the
532+
* `--dry-run` branch in #16480, and `--json` is if anything the more
533+
* likely CI spelling of the two — a pipeline that wants to parse the
534+
* result reaches for it. A check that cannot fail is indistinguishable
535+
* from a check that finds nothing.
536+
*
537+
* ## Why the failure is this command's `{ error }` envelope and NOT a
538+
* new payload member
539+
*
540+
* ⛔ The drift report is deliberately NOT widened into the published
541+
* payload — no `drift` / `missing` / `stale` member is added here. This
542+
* command already has exactly one machine-readable failure envelope,
543+
* twenty lines down in the `catch`: `{ error, …errorCodeFields }`,
544+
* compact, exit 1. Every other way this command can fail already speaks
545+
* it, the `--check` needs-`--out` refusal above included, so routing
546+
* drift through the same `throw` is copying the convention rather than
547+
* settling a second one for the same mode. Which files drifted is a
548+
* genuine addition to a published output face and is its own card.
549+
*
550+
* ⚠️ And it must stay ONE document: emitting the payload here and an
551+
* error envelope afterwards is the two-JSON-documents defect
552+
* {@link isExitSignal} records — unparseable as either one document or
553+
* as JSONL. So the verdict is reached before anything is written, and
554+
* the run leaves through exactly one of the two faces.
555+
*
556+
* ⛔ Returning 0 without comparing must not come back.
557+
*/
558+
if (flags.check) {
559+
if (!flags.out) throw new Error(CHECK_NEEDS_OUT);
560+
const { missing, stale } = compareCommitted(emittedFiles(outDir as string));
561+
if (missing.length > 0 || stale.length > 0) throw new Error(driftMessage());
562+
}
435563
await emitJson({
436564
totalExpected: result.totalExpected,
437565
// Leaves of the `bundles` payload below, locale by locale, so this
@@ -526,7 +654,7 @@ export default class I18nExtract extends Command {
526654
console.log('');
527655

528656
if (flags.check && !flags.out) {
529-
throw new Error('--check needs --out=<dir> — it compares a fresh extract against the bundles committed there.');
657+
throw new Error(CHECK_NEEDS_OUT);
530658
}
531659

532660
/**
@@ -579,39 +707,12 @@ export default class I18nExtract extends Command {
579707
// under `--check`, and `--check` without `--out` already threw.
580708
const resolvedOutDir = outDir as string;
581709

582-
// Every file a normal run would emit, paired with its rendered content.
583-
// Both branches below iterate this, so `--check` can never diverge from
584-
// what a real extract writes.
585-
const emitted: Array<{ file: string; content: string; keys: number }> = [];
586-
for (const locale of localesEmitted) {
587-
for (const mod of emittedModules(locale)) {
588-
emitted.push({
589-
file: path.join(resolvedOutDir, `${locale}.${mod.suffix}`),
590-
content: renderTranslationModule(result.bundles[locale], { locale, kind: mod.kind }),
591-
keys: mod.keys,
592-
});
593-
}
594-
// The provenance companion rides in the SAME list, so `--check` compares
595-
// it by the same byte-for-byte rule as the bundles it belongs to and can
596-
// never diverge from what a real extract writes.
597-
const table = committedSourceHashes(locale);
598-
if (flags['source-hashes'] && table) {
599-
emitted.push({
600-
file: path.join(resolvedOutDir, `${locale}.source-hashes.generated.ts`),
601-
content: renderSourceHashModule(table, { locale }),
602-
keys: Object.keys(table).length,
603-
});
604-
}
605-
}
710+
// Every file a normal run would emit, paired with its rendered content —
711+
// {@link emittedFiles}, the same list the machine face compares.
712+
const emitted = emittedFiles(resolvedOutDir);
606713

607714
if (flags.check) {
608-
const stale: string[] = [];
609-
const missing: string[] = [];
610-
for (const { file, content } of emitted) {
611-
const shown = displayPath(file);
612-
if (!fs.existsSync(file)) missing.push(shown);
613-
else if (fs.readFileSync(file, 'utf8') !== content) stale.push(shown);
614-
}
715+
const { missing, stale } = compareCommitted(emitted);
615716
if (missing.length === 0 && stale.length === 0) {
616717
console.log('');
617718
printSuccess(`${emitted.length} bundle(s) are in sync with the schema ${chalk.dim(`(${timer.display()})`)}`);
@@ -621,15 +722,11 @@ export default class I18nExtract extends Command {
621722
for (const shown of stale) printError(`out of date: ${shown}`);
622723
console.log('');
623724
// The command that regenerates these bytes is THIS run without
624-
// `--check` — the two branches share the `emitted` list above, so the
725+
// `--check` — the two faces share the `emittedFiles` list above, so the
625726
// write path cannot produce anything other than what was just
626-
// compared. {@link rerunWithoutCheck} says why it is spelled as a
627-
// deletion and what the degraded line is for.
628-
const rerun = rerunWithoutCheck(this.config.bin, this.id, this.argv);
629-
printError(
630-
'Translation bundles have drifted from the schema. Regenerate and commit:\n' +
631-
(rerun ? ` ${rerun}` : ' re-run the same command without `--check`'),
632-
);
727+
// compared. {@link driftMessage} is the sentence, built once so the
728+
// `--json` face ends on the same words.
729+
printError(driftMessage());
633730
process.exit(1);
634731
}
635732

0 commit comments

Comments
 (0)