fix(cli): i18n extract --check --json compares instead of exiting 0 - #16672
Conversation
…#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 Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 434297d59130c3d9013dec72e6663eb1e690388b && git checkout 434297d59130c3d9013dec72e6663eb1e690388b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 67ccfe8efb815e7b43c16b0b6c095e2a240e28e2 3fd7a6b0a6ad2f28342e222df3b2cfb20af66a10 && git checkout -B drift-repro 67ccfe8efb815e7b43c16b0b6c095e2a240e28e2 && git merge --no-ff 3fd7a6b0a6ad2f28342e222df3b2cfb20af66a10
node scripts/docs-audit/affected-docs.mjs --json 67ccfe8efb815e7b43c16b0b6c095e2a240e28e2
|
… 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
…uling 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
os-sales
left a comment
There was a problem hiding this comment.
At-tier contract review (CONTRACT_REVIEW_TIER) — card #16600
Dispatched by the domain:cli PM seat (#6024) with the Clause-② classification itself explicitly delegated. Reviewed at head 75fc1c3423 (code identical to 7956a7c308; cba32a42b9 is comment-only, 75fc1c34 is changeset-only). Verification ran in a dedicated worktree at this head with the CLI's dependency closure built.
Verdict: PASS-WITH-FINDINGS · Clause-②: no
One finding must be fixed before this goes ready (F1 — the machine face's regenerate command). The contract question is settled below. ⛔ This is a verdict, not an approval; the queue and the maintainer decide.
The ruling: Clause-②: no — the published face did not move; a declared contract was enforced
The criterion, from the rulebook rather than from memory: Clause-② asks whether the card relaxes the accept set or expands the public face (.claude/skills/pm-dispatch/SKILL.md — 「判据:本卡放宽接受集或扩大公开面吗」; 「条款②只指已发布契约面」), and the same rulebook says 「拉回已声明契约按常规档」 — pulling behaviour back to an already-declared contract is the regular tier. The lane spec is direction-explicit: 「放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,不触条款②」 (references/lanes/spec.md). Measured against the diff:
- Accept set — narrowed toward the declared contract, not relaxed. Nothing is newly accepted. Two inputs that were silently accepted against the declared contract are now refused as the contract says. The contract text: the
--checkflag's own help (extract.ts): "Write nothing; fail if the committed bundles in --out differ from a fresh extract"; andcontent/docs/ui/translations.mdx: "Fail if the committed bundles have fallen behind the schema" / "--checkwrites nothing: it re-renders and diffs against--out". The exit 0 on a drifted tree under--jsonwas never a documented behaviour of the flag pair — no page documents the pair at all (i18n-standard.mdxdocuments--jsonalone as "Print the skeletons as JSON instead of writing files"). The 0 → 1 transition is the repair of a check that never checked, which is exactly the 「拉回已声明契约」 case. - Public face — unchanged in shape. Under
--json, stdout could already carry exactly two documents: the extract payload, or{ error, ...errorCodeFields }from thecatch(extract.ts:745-749, verified — identical toi18n/check.ts:186andlint.ts:995). After this PR it can carry the same two, and only one per run. No member added, removed or retyped; no new errorcodeminted (the driftErrorcarries none, soerrorCodeFieldsyields{}— a new code would have been an automaticyesper the ERROR_CODE_LEDGER rule); theerrorsentence's value is, byformat.ts's own account, "an English sentence that no contract pins". The exit-code set stays {0, 1}.node scripts/pm/check-widening-tells.mjs --declaration no --diff -over this diff: 3 files read, no widening tell, exit 0. - Triage's own scoping agrees. Triage split the card and wrote of the exit-code half: 「⭐ 这一条就足以杀死假绿,且不新增任何公开输出形状」 — needing no ruling. The ruling-needing half was the payload shape. The sentence the PM relied on — 「若照抄了既有约定 ⇒ 那是加宽已发布输出」 — infers a widening from the copying; that inference is the factual predicate delegated to this tier, and on this diff it does not hold: copying the
catchenvelope produced no payload shape. The dev delivered the no-ruling half and left the widening (naming the drifted files) unrouted, which triage forbade settling here. - Precedent, same command, same class, today. #16480 (the
--dry-runtwin, PR #16627): a documented flag pair went from exit 0 to exit 1 on drift; claimedClause-②: no("CLI flag semantics inside the dev's remit — no contract review needed"), gradedpatch, shipped with the same "pipelines may go red" disclosure, merged. The PM's argument (b) — a documented pair now exits 1 — applied identically there. The only candidate discriminator here is the envelope routing, and (2) shows it adds no shape. - The level rule corroborates. "WHICH LEVEL" (
pr-automation.yml, maintainer 2026-09-04 batch #35): "afix(that changes no public surface stayspatch." Ayeshere forcesminorfor a change that grew no surface — the self-contradiction the level axis exists to catch, reached from the other side.
On the PM's asymmetry argument: it is right, and it is why this review happened; the conservative default did its job by landing the question here. The one repo precedent of an accept→reject flip graded yes (plugin-approvals C-a/C-b) is a runtime approver-screening change — the rulebook's 「运行时权限/安全行为变更不是条款②」 negative boundary — and does not carry over to a CLI exit code enforcing its own help text.
What the ruling changes on this PR (for the PM to route; ⛔ none of it is mine to write):
- Card: the governing
Claim:comment (5573754600) is the carriercardDeclarationreads — a re-judgement must be edited into that comment (or a newerClaim:naming the same branch); a separate ruling comment is not read once the claim itself declaresyes. Then clearneeds:contract-reviewfrom both carriers. - PR body: replace the heading
## Clause-②: nowith a bare lineClause-②: no—readClause2Linereads the heading as a near-miss (that is whatCheck Changesetreported), and with the carrier off the level axis then readsnot-declaredrather thannot-measured. - Changeset: back to
patch, and drop the paragraph added in75fc1c34that ties the grade to the PM'syes— under this ruling that paragraph states a reason that no longer exists.
Findings
F1 — must fix before ready · the --json drift envelope names a regenerate command that regenerates nothing. rerunWithoutCheck removes only --check from argv, so under --json the prescribed remedy keeps --json. Measured at this head on the card's fixture:
$ os i18n extract CONFIG --locales=zh-CN --no-metadata-forms --out=OUT --check --json
{"error":"Translation bundles have drifted from the schema. Regenerate and commit:\n os i18n extract CONFIG --locales=zh-CN --no-metadata-forms --out=OUT --json"}
-> exit 1
$ os i18n extract CONFIG --locales=zh-CN --no-metadata-forms --out=OUT --json # the named command
-> exit 0, files in OUT: 0
--json is "Output JSON instead of writing files", so an operator (or a CI log reader) who does what the failure says gets a payload, writes nothing, and the next --check --json fails again with the same advice — the #14895 loop ("the failure is self-healable and the advice is what stops it healing") on the face this PR creates. The changeset's consumer-facing line "The fix is the one the failure names" is therefore wrong under --json. Fix within remit: drop --json (and --json=…) alongside --check when building the rerun line — the function's contract is "the command that regenerates these bytes", and a --json run is not that — and reword its "removes one token" note; add one assertion to the two drift cases that the error's second line contains --out= and not --json (today jsonVerdict reads only the first line, which is why the pin did not catch this).
F2 — PR body declaration spelling. See above: a bare Clause-②: no line. The fixed spelling readClause2Line accepts is Clause-②: yes|no at line start (optionally blockquoted / bulleted / bolded), not a heading.
F3 — changeset grade and banner. Under no: patch, per WHICH LEVEL. The dev's reasoning for no **BREAKING** banner and no ADR-0087 disposition is right and I concur on the merits, independent of the level: nothing an author can write is removed or narrowed, the failure prescribes no consumer code change, and ADR-0087 is the metadata migration ledger (check-adr-0087-registration.mjs) — a CLI exit code enforcing its documented semantics is not a migration. The relayed rule ("breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level") is verified verbatim at pr-automation.yml WHICH LEVEL and in check-changeset-no-major.mjs's header. The @objectstack/cli's CHANGELOG; amend its remedy sentence once F1 lands ("re-run without --check — and without --json").
Items judged, with evidence
- Envelope choice — correct.
{ error, ...errorCodeFields }, compact, exit 1 is this command's only machine-readable failure face (catch,extract.ts:745-749) and the family's (i18n check,lint). Routing the needs---outrefusal there is not a second silent change: it is the other thing the same earlyreturnskipped, listed as row 3 of the card's own table, reached by the same thrownError. - Single-document purity — holds. By reading: the only stdout writes ahead of the
--jsonbranch are guarded (if (!flags.json) printHeader/printStep/printInfo); under--jsonthe payload is emitted only after the verdict (extract.ts:557-563), and thecatchis the only other stdout writer.isExitSignal(format.ts:129) is real and the two-document reasoning is right — payload-then-envelope would parse as neither one document nor JSONL. The pin assertsdocuments: 1on every machine run. - The deliberate non-widening — right, and the asymmetry is defensible as shipped once F1 is fixed. The machine face reports that, not which; the actionable branch for a pipeline is exit 1 plus a working regenerate command. Naming the files is additive, follows
os lint --json/os i18n check --json's report-plus-derived-exit shape, and is aClause-②: yes,minorcard of its own. Without F1 the machine face would have neither the file list nor a usable remedy, which is why F1 is a blocker and not a nit. - The pin — discriminates. Ablation at this head: base blob
bf9f8909e0checked out under the finished branch (hash-proven),vitest --project integration→ 3 failed / 2 passed: the two drift cases and the needs---outcase red in the predicted direction ({ status: 0, error: null, payload: true }against{ status: 1, error: "Translation bundles have drifted…", payload: false }), in-sync and no---checkgreen on both sides as designed; restore proven by blobabcb73a72band an emptygit status --porcelain. On HEAD: 5/5. Tier:vitest-tiers.tsclassifies the fileintegrationonchildProcess, entryBasename, tsxBin, and without the.e2ename it is in theOS_TEST_TIERS=queuepopulation (vitest.config.ts), so it runs per-PR — the right trade for a regression that reads green.erroris asserted as a first-line string,payloadas a boolean, and the two-invocation equality is stated beside spelled-out values, so two runs that both compare nothing cannot satisfy it.
CI at review time: Check Changeset red is the level axis reading the carrier as yes against patch (now minor at 75fc1c34) — resolved by the ruling above, not evidence for either side; everything else on the head green or in flight.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Generated by Claude Code
…tes 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
Fixes #16600
os i18n extract CONFIG --locales=zh-CN --no-metadata-forms --out=DIR --check --jsonexited 0 having compared nothing, on a tree whose bundles had provably drifted. The same invocation without--jsonexited 1 on the same fixture and printedTranslation bundles have drifted from the schema— the card's own positive control, so the exit 0 was a reading and not "nothing to compare".Re-verified on today's
mainbefore any repairThe card's dispatch asked for this because the
--dry-runtwin (#16480) landed as PR #16627 earlier today and moved the control flow of this same function. It did not incidentally repair this one. Measured at0a61db1f5d(the branch point, today'sorigin/main), by checking the pre-repair source back out under the finished branch and driving the card's own two invocations:… --out=OUT --checkmissing: OUT/zh-CN.objects.generated.ts, then the drift sentence… --out=OUT --check --json… --check --jsonwith no--outif (flags.json) { await emitJson(...); return; }sat ahead of both the--checkneeds---outguard and the comparison block.--jsonis if anything the more likely CI spelling of the two, because a pipeline that wants to parse the result reaches for it.What this changes
Under
--json,--checkis a verdict mode, so the comparison now runs before the one document the run is allowed to write. Four invocations, and only the first two move:{ "error": ... }envelope with exit 1, carrying the same sentence the console face prints, regenerate-and-commit command included.--checkwith no--out— the refusal is now reachable on the machine face, in the same envelope, exit 1.--jsonwithout--check— unchanged in every respect.The run leaves through exactly one of those faces, so stdout still parses as exactly one JSON document. Emitting the payload and then an error envelope would be the two-document defect
isExitSignalrecords inutils/format.ts.The file list and the comparison became one closure each —
emittedFiles,compareCommitted,driftMessage— read by both faces, so the console and machine--checkcannot disagree about what this run produces or end on different words.--check --jsonand was green may now go red, and that is this repair working. The green was a comparison that never happened; the red is drift that was already in the tree. The changeset says so in the words that ship to consumers.Clause-②
Clause-②: no
⭐ Ruled at the contract-review tier on this PR, after the PM initially judged it
yes; both carriers are cleared. The criterion is directional — does the diff relax the accept set or expand the public face — and neither happens here: nothing is newly accepted, two inputs that were silently accepted against the declared contract are now refused as--check's own help text says, and the--jsonstdout set was already {payload} ∪ {{error, ...}} and still is. The--dry-runtwin, #16480 / PR #16627 — same command, same class, merged this morning — wentClause-②: no+patchon exactly this shape. ⛔ This is not a general rule that copying an envelope is never a widening; it is that on this diff the copying produced no payload shape.The changeset is
patchaccordingly: afix(that changes no public surface stayspatch.Why the payload is not widened, which is the question the declaration turns on. No
drift/missing/stalemember is added to any payload, and no member of the existing payload changes. This command has exactly one machine-readable failure envelope —{ error, ...errorCodeFields }, compact, exit 1, in this same file'scatch— and every other way the command can fail already speaks it, the--checkneeds---outrefusal included, which is the same mode as the drift refusal and is now routed there too. Giving drift a different shape than the refusal beside it would have been the third spelling triage forbade. Naming the drifted files on the machine face is additive, can land later without breaking anyone, and stays the PM's to route as its own card.os lint --jsonandos i18n check --jsonreport a verdict in the payload with the exit code derived from the same count; that is the shape such a widening would copy, and neither is foreclosed here.Breaking-ness: NOT declared
No
**BREAKING**banner and no ADR-0087 disposition, concurred at the at-tier review independently of the level. Nothing an author can write is removed, renamed, or narrowed: no spec key, no export, no config field, no type surface. There is no FROM → TO mapping to ship and no tombstone to write, and ADR-0087 is the metadata migration ledger — a CLI exit code enforcing its own documented semantics is not a migration. The flag's published description isWrite nothing; fail if the committed bundles in --out differ from a fresh extract, which the old--jsonpath contradicted and the new one honours. ⛔ No banner was added "to be safe", which would assert a migration prescription that does not exist, and none was omitted to dodge the gate, which is opt-in by declaration.The remedy the machine face prints actually heals (review finding F1)
⭐ Caught at review, and it was a real blocker rather than a nit:
rerunThatRegenerates(wasrerunWithoutCheck) stripped only--check, so on the machine face the command the failure named kept--json— "output JSON instead of writing files". An operator or CI log reader who ran exactly what the failure printed got a payload, wrote zero files, and the next--check --jsonfailed again with the same advice. That is the #14895 loop — "the failure is self-healable and the advice is what stops it healing" — reproduced on the very face this PR creates.Now the helper drops
--jsonand--json=...alongside--check, and the degraded fallback line names the same two tokens under--jsonso the built command and the fallback cannot prescribe different things. Driven end to end on the card's fixture:jsonVerdictread only the first line of a two-line envelope. That is the same partial-reading shape this card family is about, so the remedy is now pinned on its own two booleans —remedyNamesOutandremedyCarriesJson— asserted on every machine case.How it was verified
All readings below are on head
3fd7a6b0a6, exit codes captured before any pipe.packages/cli/test/i18n-extract-check-json.test.ts, 5 cases,integrationproject —Test Files 1 passed,Tests 5 passed, exit 0. Every case is the card's required two-invocation contrast — same fixture, same--out, differing only by--json— and none of them asserts the exit code alone: each drift case pins the reported drift and the remedy read out of the JSON document, plus a purity assertion that stdout parses as exactly one document, plus the filesystem staying untouched. Not named.e2eon purpose: this regression reads GREEN, so it has to run on pull requests and in the queue rather than nightly.@objectstack/cliunit tier:Test Files 183 passed,Tests 2511 passed | 6 expected fail, exit 0.typecheckexit 0.git hash-objectmatching that commit's blob, then restored withgit checkout HEAD -- ...and proven back by hash and a whole-treegit status --porcelain. Result: 3 failed / 2 passed — the two drift cases and the needs---outcase go red; the in-sync case and the plain---jsoncase stay green.--jsonarm from the remedy helper: 2 failed / 3 passed, the two drift cases, on theremedyCarriesJson/remedyNamesOutassertions and nothing else. So the remedy pin discriminates by itself rather than riding on the exit-code cases.--check --jsonon an in-sync tree also exited 0 with the payload. That is the [finding]os i18n extract --check --dry-runexits 0 having compared nothing — a--checkthat passes without checking, i.e. a false green in CI #16480 lesson restated in this card's terms: an exit-code-only suite would have stayed green over the very regression it was written for. Its job here is to stop "always fail under--check --json" from satisfying every other case in the file.eslint . --no-inline-configis CI's; locally it is narrowed to the two.tsfiles this diff touches,0 errors 0 warnings, exit 0. The narrowing is a measurement, not a skip: the population is read from eslint's own config (**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}; the changeset.mdanswersFile ignored because no matching configuration was supplied, so 2 of 3 changed paths are lintable), the count of 2 comes from--format json, and this repo's singleeslint.config.mjsnever enables type-aware linting for any file — its own measured note says so — so nothing in this diff can move an untouched file's verdict.验收备注
Noted while working, not filed — none of them is a reproducible defect, a stated-contract violation, or an authoring trap, so none of them earns a card:
--jsonpayload carries two count semantics side by side —countsis what was emitted,metadataFormsCountsis what was built. The file's own comment already says so and already says the question of whether it should is the maintainer's. Untouched here.--check --jsonrun now reports that the bundles drifted but not which ones, where the console face names each file. That is the widening described under Clause-② above, deliberately left to the PM to route.--checkis the only flag of its kind in this CLI (packages/cli/src/commands/), so there was no same-mode precedent to copy — only same-command and same-family ones. Recorded so the next reader does not re-derive it.Opened from the
os-devseat using Claude Code; this sentence is the durable attribution, since a trailing footer block on a PR body is rewritten by the platform.Generated by Claude Code