Skip to content

Commit 591f194

Browse files
claude[bot]claude
andauthored
fix(cli): i18n extract --check --json compares instead of exiting 0 (#16672)
* 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 * docs(cli): correct the distance the `--json` note gives for the catch block The note said the `{ error, …errorCodeFields }` envelope sits "twenty lines down"; it is at the end of the method, ~200 lines below. Comment only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ * chore(changeset): grade `@objectstack/cli` minor under the clause-② ruling The PM ruled clause-② `yes` on card #16600: copying this command's existing `{ error, ... }` envelope onto a path that could not reach it is the widening branch triage enumerated. `check-changeset-no-major`'s way 1 then applies — the declaration is right and the level was wrong. Records why no BREAKING banner rides with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ * fix(cli): the drifted `--check` remedy drops `--json`, which regenerates nothing `rerunThatRegenerates` (was `rerunWithoutCheck`) stripped only `--check`, so on the machine face the command it named kept `--json` — "output JSON instead of writing files". Running exactly what the failure printed emitted a payload, wrote zero files, and left the next `--check --json` failing with the same advice: the #14895 loop reproduced on the face this branch creates. Measured end to end on the card's fixture; after the change the printed command writes the bundle and the following `--check --json` exits 0. The degraded fallback line names the same two tokens under `--json`, so the built command and the fallback cannot prescribe different things. The pin reads the remedy as well as the sentence: the drift envelope is two lines and the second one is where this face can go wrong on its own, which is why reading only line one let this through. `remedyNamesOut` / `remedyCarriesJson` are asserted on every machine case. Changeset back to `patch` under the at-tier `Clause-②: no` ruling, and its remedy sentence amended to name both flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5c4022 commit 591f194

3 files changed

Lines changed: 592 additions & 54 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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 without `--json`**, then commit what it writes. Neither of those two flags writes files, and the command the failure prints now has both taken out of it.
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.
19+
20+
One more thing moved with it: the command a drifted `--check` prints as its remedy now has `--json` taken out of it as well as `--check`. It used to keep `--json`, so the machine face named a command that emits a payload, writes zero files, and leaves the next run failing with the same advice.

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

Lines changed: 180 additions & 54 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.
@@ -95,40 +104,60 @@ function shellToken(token: string): string {
95104
* An assembled command is wrong in exactly one way and it is unbounded — every
96105
* flag that exists now, and every flag added later, has to be remembered at
97106
* this print site or it silently goes missing. So this does not enumerate
98-
* flags at all. It takes the argv oclif was handed and removes one token from
99-
* it, which makes the echo correct for flags this file has never heard of.
107+
* flags at all. It takes the argv oclif was handed and removes the tokens that
108+
* make a run WRITE NOTHING, which keeps the echo correct for flags this file
109+
* has never heard of.
110+
*
111+
* ## Which tokens, and why it is not just `--check` (#16600)
112+
*
113+
* There are exactly two, and both are "write nothing" spellings:
114+
*
115+
* - `--check` — the mode being escaped. Removing it is the whole point.
116+
* - `--json` — "output JSON instead of writing files", so a run carrying it
117+
* regenerates nothing either. It became reachable here the moment the
118+
* machine face started reporting drift, and until it was dropped this
119+
* function named a command that emits a payload, writes zero files, and
120+
* leaves the next `--check --json` failing with the same advice: the
121+
* #14895 loop above, reproduced one face over. A remedy that cannot heal
122+
* the failure it is printed under is worse than none, because it looks
123+
* like one.
100124
*
101-
* ⛔ It also never GUESSES. If `--check` is not in the argv the flag was not
102-
* spelled there, this function cannot point at what it removed, and the caller
103-
* prints "re-run the same command without `--check`" instead — the degraded
104-
* line the report itself asked for, on the grounds that a correct vague
105-
* sentence beats a complete-looking wrong command. Today's flag surface has no
106-
* other way to set `--check` (no `env`, no default, no `allowNo`), so that is
107-
* defence rather than a path a user can reach; it is what keeps "assemble an
125+
* ⛔ It never GUESSES. If `--check` is not in the argv the flag was not spelled
126+
* there, this function cannot point at what it removed, and the caller prints a
127+
* degraded sentence instead — on the grounds that a correct vague sentence
128+
* beats a complete-looking wrong command. `--json`'s absence is NOT such a
129+
* signal: it is dropped when present and its absence means only that the run
130+
* was on the console face. Today's flag surface has no other way to set
131+
* `--check` (no `env`, no default, no `allowNo`), so the guard is defence
132+
* rather than a path a user can reach; it is what keeps "assemble an
108133
* approximation" from ever becoming the fallback.
109134
*
110135
* `--` is honoured because it changes what a token MEANS: after it, `--check`
111136
* is a positional argument and removing it would rewrite the invocation rather
112-
* than trim it.
137+
* than trim it. The same holds for `--json`.
113138
*
114139
* @param bin `config.bin` — `os`, the name the command is installed under
115140
* @param id `this.id` — `i18n:extract`, oclif's colon spelling of the path
116141
* @param argv `this.argv` — the arguments as typed, the command id stripped
117142
* @returns the command to print, or `undefined` when it cannot be built
118143
*/
119-
function rerunWithoutCheck(bin: string, id: string | undefined, argv: readonly string[]): string | undefined {
144+
function rerunThatRegenerates(bin: string, id: string | undefined, argv: readonly string[]): string | undefined {
120145
const kept: string[] = [];
121-
let dropped = 0;
146+
let droppedCheck = 0;
122147
let afterTerminator = false;
123148
for (const token of argv) {
124149
if (!afterTerminator && token === '--') afterTerminator = true;
125150
else if (!afterTerminator && (token === '--check' || token.startsWith('--check='))) {
126-
dropped += 1;
151+
droppedCheck += 1;
152+
continue;
153+
} else if (!afterTerminator && (token === '--json' || token.startsWith('--json='))) {
154+
// Dropped without being counted: only `--check`'s absence means "this
155+
// function cannot say what it removed".
127156
continue;
128157
}
129158
kept.push(token);
130159
}
131-
if (dropped === 0) return undefined;
160+
if (droppedCheck === 0) return undefined;
132161
return [bin, ...(id ?? 'i18n:extract').split(':'), ...kept.map(shellToken)].join(' ');
133162
}
134163

@@ -431,7 +460,135 @@ export default class I18nExtract extends Command {
431460
return narrowToCommittedSections(table, committed);
432461
};
433462

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

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

532689
/**
@@ -579,39 +736,12 @@ export default class I18nExtract extends Command {
579736
// under `--check`, and `--check` without `--out` already threw.
580737
const resolvedOutDir = outDir as string;
581738

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-
}
739+
// Every file a normal run would emit, paired with its rendered content —
740+
// {@link emittedFiles}, the same list the machine face compares.
741+
const emitted = emittedFiles(resolvedOutDir);
606742

607743
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-
}
744+
const { missing, stale } = compareCommitted(emitted);
615745
if (missing.length === 0 && stale.length === 0) {
616746
console.log('');
617747
printSuccess(`${emitted.length} bundle(s) are in sync with the schema ${chalk.dim(`(${timer.display()})`)}`);
@@ -621,15 +751,11 @@ export default class I18nExtract extends Command {
621751
for (const shown of stale) printError(`out of date: ${shown}`);
622752
console.log('');
623753
// The command that regenerates these bytes is THIS run without
624-
// `--check` — the two branches share the `emitted` list above, so the
754+
// `--check` — the two faces share the `emittedFiles` list above, so the
625755
// 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-
);
756+
// compared. {@link driftMessage} is the sentence, built once so the
757+
// `--json` face ends on the same words.
758+
printError(driftMessage());
633759
process.exit(1);
634760
}
635761

0 commit comments

Comments
 (0)