fix(i18n): translate the platform packages' declared surface; gate all nine bundles, not one (#3762) - #3780
Merged
Conversation
…l nine bundles, not one (#3762) Only `platform-objects` was wired into a translation-drift check. The other eight packages shipped a `scripts/i18n-extract.config.ts` that nothing ever ran, and four of them had already drifted out of sync with the schema — the same rot `pnpm check:i18n` exists to catch, one directory over. Translated: plugin-security (45 strings per locale), plugin-webhooks (15), plugin-audit (8), plugin-sharing (7) and service-storage (7) are now at zero untranslated declared strings in zh-CN / ja-JP / es-ES — 246 translations. Most were newly visible rather than newly missing; #3753 taught the coverage detector to walk action params, resultDialog, listViews and the rest, and this is what it found. Wording was harvested from the repo's own bundles wherever a string was already translated somewhere (1382 unambiguous sources), so terminology matches what ships elsewhere instead of being reinvented. Protocol tokens stay identical across locales on purpose: GET/POST/PUT/PATCH/DELETE, ETag, ACL, URL. Gated: `scripts/check-i18n-bundles.mjs` replaces the single-package check and covers all nine. It does not restate each command — it parses the one already documented in that config's own docstring and runs it, so the documented regenerate command and the gate cannot diverge. The coverage ratchet grows from `examples/*` to twelve configs; eight sit at zero, making it the strict gate there. Fixed a truncation bug this exposed: `os lint --json` on a large config came out of a pipe cut off at exactly 65536 bytes. `console.log(big)` then `process.exit(1)` tears the process down before an async pipe write drains, while an interactive run (TTY, written synchronously) looks perfect — so every scripted consumer silently got invalid JSON. `emitJson` waits for the drain and sets `process.exitCode`; lint, i18n check and i18n extract use it. ~30 other CLI commands share the pattern and are untouched. Not fixed here: platform-objects' own 77-per-locale gap is apps/dashboards navigation and widget labels, outside the `objects` subtree and unscaffoldable while the package extracts with `--objects-only`. Needs an emit decision — #3762. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 32 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…kages The guardrail table named only platform-objects and pointed at `pnpm i18n:extract`. Eight more packages now have gated bundles, and the command that regenerates all of them is `check-i18n-bundles.mjs --write`. Also states what the gate actually checks, which the old wording left ambiguous: translation VALUES are hand-written and editing one is fine — the check runs in merge mode and preserves it. Adding or dropping KEYS is the drift it fails on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
os-zhuang
marked this pull request as ready for review
July 28, 2026 05:20
os-zhuang
added a commit
that referenced
this pull request
Jul 28, 2026
…e the debt was 1 string, not 231 (#3803) Two follow-ups to #3780. Both were smaller in the work and larger in the finding than the issue text implied. ## 1 — `--json` truncation: every command, and the second document it hid #3780 routed THREE commands through `emitJson`. The other ~100 emission sites still wrote machine output with `console.log`, which on a PIPE is cut off at one 64 KiB buffer when the command exits right after. Invisible to whoever writes it: stdout to a TTY is synchronous, so every interactive run looks perfect while every scripted consumer — the only audience `--json` has — gets invalid JSON. The exit need not be explicit. oclif ends failing commands with `handle()` → `Exit.exit()` → `process.exit()` and flushes nothing on that path (`flush()` runs only on `execute()`'s success path), so a plain `this.exit(1)` or any thrown error truncates identically. Measured on a 300 009-byte payload: `process.exit(1)` 65 536, `throw` 65 536, natural return 300 009, `process.exitCode = 1` 300 009. 73 of the 104 sites had that shape, and `lint` was itself only half fixed by #3780 — `--eval --json` writes a whole corpus report and was still on `console.log`. - All 104 sites go through `emitJson`; `formatOutput`'s json/yaml branches through the same drain-aware `emitText` (`--format yaml` truncated too), making it async — 32 call sites now await it. - Control flow untouched: a following `this.exit(1)` stays, simply safe once the buffer has drained. - Output bytes unchanged. Roughly half the sites emitted compact and half indented; `{ compact: true }` preserves whichever each had, keeping this a truncation fix rather than an output-format change. - An ESLint rule (root script runs `--no-inline-config`, so no per-site opt-out) rejects `console.log(JSON.stringify(…))` under packages/cli/src and un-awaited `formatOutput`. It earned its place immediately: #3789 landed `os validate`'s authoring lints with the old pattern and the rule caught it on the merge. Draining the write exposed a second defect underneath. Because `this.exit(1)` THROWS, a command whose body sits in one `try` unwinds its inner "report and stop" into the outer `catch`, which reports again — `os validate --json` printed TWO JSON documents, neither valid JSON nor valid JSONL. Truncation had been hiding the second one. Nine commands had this shape; their catches now re-throw the exit signal (`isExitSignal`). `os validate --json`, 900 schema errors, piped: 131 072 bytes unparseable before; 1 514 711 in two documents with the truncation fix alone; 1 514 648 as one parseable document with both. Deliberately NOT fixed by forcing stdout into blocking mode process-wide, which would be one line and cover everything: the same binary runs `os serve` / `os dev`, and a blocking write to a pipe with a slow reader blocks the event loop — trading truncated JSON for a server that stalls on its own logs. ## 2 — #3762's remainder: the debt was 1 string, not 231 The open item read "77 strings short per locale in apps.*/dashboards.*, needs an emit decision (drop --objects-only, or a companion .apps.generated.ts)". Measured, the premise did not hold. Of the 77 declared keys per locale, 76 were already translated in the hand-authored `<locale>.ts` files and had been for months. Exactly one was genuinely missing: `apps.studio.navigation.nav_app_builder.label`, absent in all four locales including `en`. The 231 was a measurement artifact — the config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its `translations` merge baseline listed only the two GENERATED subtrees. Neither proposed emit is right, and the second would have caused damage. The Setup app is a shell of empty group anchors; its ~25 entries are contributed at RUNTIME by SETUP_NAV_CONTRIBUTIONS and by capability plugins (ADR-0029 D7), so a bundle generated from a static walk is structurally incomplete — regenerating over the hand-authored files would have DELETED 40 live nav translations per locale. Dropping `--objects-only` fails differently: `kind:'full'` folds all 803 metadata-form keys into the objects bundle and renames the export the baseline imports. The split is correct and is now written down: objects/metadataForms are generated and gated by the bundle-drift check; apps/dashboards/pages are hand-authored and gated by the coverage ratchet. Only the baseline was wrong. - Extract config's `translations` carries the per-locale assemblers, with objects/metadataForms still pinned to the committed generated files. - nav_app_builder translated in all four locales, wording harvested from the repo's own precedent for "builder" (构建器 / ビルダー / generador). - nav_workflows removed from all four — its entry is gone from STUDIO_APP and nothing contributes to that app, so the translation was dead. - Ratchet baselined 231 -> 0 (repo total 996 -> 765), making platform-objects the ninth package where the ratchet is a strict gate. - A CLI-independent parity test walks the statically declared Studio and Account navigation plus the dashboard's widgets and asserts a translation in every locale, and the reverse — no translation outlives its nav item. An untranslated nav id is invisible in the UI: it falls back to the app's own English label, so a Chinese Studio menu shows one English entry among thirty. That is why this needs a gate and not a one-time sweep. ## On the gates themselves Every new gate was checked in BOTH directions — red before the fix, green after. That discipline caught a defect in my own test: the truncation control case initially raced the reader's drain, passing locally at 65 536 bytes and failing on a runner whose reader kept up. A gate that goes red by machine speed is no better than one that cannot go red, so it was rewritten to remove the race — never read the pipe until the child exits, 4 MB payload, assert only the shortfall. The technique is valid only for the broken pattern: pointed at `emitJson` it hangs forever, because awaiting the write callback is what the fix does. Verified rather than assumed. Closes #3762. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the fillable half of #3762. Follows #3370 → #3753 → #3768.
The finding that reframed this
Nine packages own a translation bundle. One was wired into a drift check. The other eight shipped a
scripts/i18n-extract.config.tsthat nothing ever ran — and four of them had already drifted out of sync with the schema:That is precisely the silent staleness
pnpm check:i18nexists to prevent, sitting one directory outside the only path it guarded.Translated
plugin-security(45 strings/locale),plugin-webhooks(15),plugin-audit(8),plugin-sharing(7),service-storage(7) — all now at zero untranslated declared strings across zh-CN / ja-JP / es-ES. 246 translations.Most of these were newly visible rather than newly missing: #3753 taught the coverage detector to walk action
params,resultDialog,listViews, objectdescription, fieldhelp/placeholder. This is what it found.Wording was harvested from the repo's own bundles first — 1382 unambiguous en→locale mappings scraped from everything already translated — so
Created Atis创建时间here because that is what it is everywhere else. Only 59 strings had no existing precedent and were written by hand. Protocol tokens are deliberately left identical across locales:GET/POST/PUT/PATCH/DELETE,ETag,ACL,URL.Gated
scripts/check-i18n-bundles.mjsreplaces the single-packagepnpm check:i18nand covers all nine (40 bundles).It does not restate each package's command. It parses the regenerate command out of that config's own docstring and runs exactly that, plus
--check— so the documented command and the gate cannot diverge. Same anti-drift principle as making the coverage detector share the extractor's walker in #3753: one source, not two that agree today.The coverage ratchet from #3768 grows the same way,
examples/*→ twelve configs:Eight at zero means the ratchet is the strict gate for them — any regression is immediately red, not merely visible.
Verified both gates bite, not just run:
DRIFTED, exit 1; restore → exit 0. (A hand-edited translation correctly does not trip it — merge mode preserves it, and the gate is about schema drift.)A real truncation bug this exposed
Wiring the ratchet to
platform-objectsfailed on unparseable JSON. The output was exactly 65536 bytes — one pipe buffer.console.log(big)followed byprocess.exit(1)tears the process down before an async pipe write drains. Interactively it looks perfect, because stdout to a TTY is written synchronously. Piped — i.e. every scripted consumer — it silently yields invalid JSON.emitJson(packages/cli/src/utils/format.ts) waits for the write callback and setsprocess.exitCodeinstead of callingprocess.exit.lint,i18n checkandi18n extractuse it;os lint --jsonon platform-objects goes from 65536 truncated bytes to 234603 that parse. ~30 other CLI commands share the pattern and are not touched here — a mechanical sweep I could not verify command-by-command in this change.The ratchet also captures to a file rather than a pipe regardless, since a gate that can read a truncated payload and report a smaller number is worse than no gate.
Also
The nine documented regenerate commands gain
--no-metadata-forms(added in #3768) — the Studio metadata-form baseline belongs toplatform-objectsalone, not a copy in every plugin.Not in scope
platform-objects' own 77-per-locale gap isapps.*/dashboards.*navigation and widget labels. Those live outside theobjectssubtree, so the package's--objects-onlyextract cannot scaffold them — which is why its--checkreports in sync while coverage reports 77 missing. That needs an emit decision (drop--objects-only, or a companion<locale>.apps.generated.ts) before any translating; it stays in #3762, and the 231 is frozen in the baseline meanwhile.Also filed: #3778 — the file-authored
objects.<name>bundles and the runtimetranslationmetadata type'so.<object>shape are unbridged, so translations cannot be authored in the product at all. Wants an ADR, not a patch.Tests
CLI suite 667 green (66 files). The five translated packages: plugin-security 673, service-storage 199, plugin-sharing 101, plugin-audit 46, plugin-webhooks 25 — all green, including their
bundle-ownershipguards. ESLint clean. Both gates green.🤖 Generated with Claude Code
https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
Generated by Claude Code