From ee0e6845faf3fccd1f5a45130a666cfbe80fc0c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:15:11 +0000 Subject: [PATCH 1/2] feat(cli): gate generated translation bundles with `os i18n extract --check` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated translation bundles had no freshness gate. Nothing failed when they fell behind the schema, so they only ever got fixed when someone happened to re-run the extractor by hand — and by the time anyone did (#3670), three distinct drifts had accumulated at once: - translations left behind for keys the schema had REMOVED (`enable.trash` / `enable.mru`, #2377; agent `visibility`, #1901) — keys the schema now rejects outright; - keys the schema had GAINED with no entry in any locale (`summaryOperations.*`, the ADR-0105 D8 invitation-placement fields, and the better-auth 1.7 columns from #3647); - `sys_migration` committed with EMPTY STRINGS for its labels in ja-JP and es-ES, which renders blank rather than falling back to anything readable. This is the same shape as #3624: an artifact and its source with no gate between them, so the gap is only found by accident. #3647 closed that one for better-auth's columns by deriving the expected surface and failing the build; this does the same for translations. `--check` renders exactly what a real extract would write — both branches iterate one shared rendered set, so the check cannot drift from the writer — then compares against `--out` instead of writing, listing every stale or missing file and printing the regenerate command. It runs in merge mode like any other extract, so it never asks for re-translation: an up-to-date bundle re-extracts byte-identically. The flag lives on the CLI rather than in a repo script, so any consumer that ships generated bundles can gate them the same way. Here it is `pnpm check:i18n`, wired into lint.yml beside the other post-build consumer gates (it reads the built spec dist through the extract config). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H --- .changeset/i18n-extract-check-flag.md | 26 +++++++ .github/workflows/lint.yml | 20 +++++ AGENTS.md | 1 + package.json | 2 + packages/cli/src/commands/i18n/extract.ts | 73 ++++++++++++++----- .../scripts/i18n-extract.config.ts | 7 ++ 6 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 .changeset/i18n-extract-check-flag.md diff --git a/.changeset/i18n-extract-check-flag.md b/.changeset/i18n-extract-check-flag.md new file mode 100644 index 0000000000..a915c9aeba --- /dev/null +++ b/.changeset/i18n-extract-check-flag.md @@ -0,0 +1,26 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): `os i18n extract --check` — fail instead of writing when translation bundles have drifted + +Generated translation bundles had no freshness gate, so they rotted silently +until someone happened to re-run the extractor by hand. #3670 found three +distinct drifts sitting in the committed bundles at once: translations left +behind for schema keys that had been REMOVED, keys the schema had GAINED with +no entry at all, and an object whose labels were committed as empty strings +(which renders blank rather than falling back to anything readable). + +`--check` writes nothing and exits non-zero when a fresh extract differs from +what is committed in `--out`, listing each stale or missing file and printing +the exact regenerate command. It runs the identical render path as a real +extract — both branches iterate the same rendered set — so the check can never +disagree with what writing would produce. + +It runs in **merge mode** like any other extract, so it never asks anyone to +re-translate: an up-to-date bundle re-extracts byte-identically. Requires +`--out`, since there is nothing to compare against without it. + +In this repo it is wired up as `pnpm check:i18n` and gated in CI, but the flag +is on the CLI, so any consumer shipping generated bundles can gate them the +same way. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cbaa07357b..53c3e95518 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -247,6 +247,26 @@ jobs: - name: Check skills TypeScript examples compile run: pnpm --filter @objectstack/spec run check:skill-examples + # Same anti-drift class as the gates above, for the generated translation + # bundles in packages/platform-objects/src/apps/translations/. Nothing + # regenerated them either, so they rotted three ways at once (#3670): + # translations left behind for keys the schema had REMOVED (`enable.trash` + # / `enable.mru`, #2377; agent `visibility`, #1901), keys the schema had + # GAINED with no entry at all (`summaryOperations.*`, the ADR-0105 D8 + # invitation-placement fields, and the better-auth 1.7 columns from + # #3647), and `sys_migration` sitting on EMPTY STRINGS in ja-JP/es-ES — + # which renders blank rather than falling back to anything readable. + # + # Every one of those was found by a human happening to re-run the + # extractor. This turns the next one into a red build. Runs in merge + # mode, so it never asks anyone to re-translate: a fresh extract of an + # up-to-date bundle is byte-identical to what is committed. + # + # Reads the built @objectstack/spec dist through the extract config, so + # it belongs after the build step with the other consumer gates. + - name: Check generated translation bundles are in sync with the schema + run: pnpm check:i18n + # Seed the shared Turbo cache from main only (see the restore step above). - name: Save Turbo cache (main only) if: always() && github.event_name == 'push' diff --git a/AGENTS.md b/AGENTS.md index 15064791cf..376dea61b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d | Path | Type | Rule | |:---|:---|:---| | `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. | +| `packages/platform-objects/src/apps/translations/*.generated.ts` | **AUTO-GEN** | ❌ Never hand-edit. Run `pnpm i18n:extract` (merge mode — existing translations are preserved). `pnpm check:i18n` gates it in CI. | | `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. | | `content/docs/concepts/` | hand-written | ✅ | | `content/docs/getting-started/` | hand-written | ✅ | diff --git a/package.json b/package.json index da3e90096f..924d9c551b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh", "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "eslint . --no-inline-config", + "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --out=packages/platform-objects/src/apps/translations", + "check:i18n": "pnpm i18n:extract --check", "check:nul-bytes": "node scripts/check-nul-bytes.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs", "check:role-word": "node scripts/check-role-word.mjs", diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index bf9bf0bcc0..1ec17a4ad3 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -85,6 +85,10 @@ export default class I18nExtract extends Command { description: 'Print to stdout instead of writing to --out', default: false, }), + check: Flags.boolean({ + description: 'Write nothing; fail if the committed bundles in --out differ from a fresh extract', + default: false, + }), }; async run(): Promise { @@ -153,6 +157,10 @@ export default class I18nExtract extends Command { } console.log(''); + if (flags.check && !flags.out) { + throw new Error('--check needs --out= — it compares a fresh extract against the bundles committed there.'); + } + if (flags['dry-run'] || !flags.out) { for (const locale of localesEmitted) { if (result.counts[locale] === 0 && metadataFormsCounts[locale] === 0) continue; @@ -174,30 +182,59 @@ export default class I18nExtract extends Command { } const outDir = path.resolve(process.cwd(), flags.out); - fs.mkdirSync(outDir, { recursive: true }); - let written = 0; + + // Every file a normal run would emit, paired with its rendered content. + // Both branches below iterate this, so `--check` can never diverge from + // what a real extract writes. + const emitted: Array<{ file: string; content: string; keys: number }> = []; for (const locale of localesEmitted) { if (result.counts[locale] > 0) { - const file = path.join(outDir, `${locale}.objects.generated.ts`); - fs.writeFileSync( - file, - renderTranslationModule(result.bundles[locale], { locale, objectsOnly }), - 'utf8', - ); - written += 1; - printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${result.counts[locale]} keys)`); + emitted.push({ + file: path.join(outDir, `${locale}.objects.generated.ts`), + content: renderTranslationModule(result.bundles[locale], { locale, objectsOnly }), + keys: result.counts[locale], + }); } if (metadataFormsCounts[locale] > 0) { - const file = path.join(outDir, `${locale}.metadata-forms.generated.ts`); - fs.writeFileSync( - file, - renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }), - 'utf8', - ); - written += 1; - printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${metadataFormsCounts[locale]} keys)`); + emitted.push({ + file: path.join(outDir, `${locale}.metadata-forms.generated.ts`), + content: renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }), + keys: metadataFormsCounts[locale], + }); } } + + if (flags.check) { + const stale: string[] = []; + const missing: string[] = []; + for (const { file, content } of emitted) { + const rel = path.relative(process.cwd(), file); + if (!fs.existsSync(file)) missing.push(rel); + else if (fs.readFileSync(file, 'utf8') !== content) stale.push(rel); + } + if (missing.length === 0 && stale.length === 0) { + console.log(''); + printSuccess(`${emitted.length} bundle(s) are in sync with the schema ${chalk.dim(`(${timer.display()})`)}`); + return; + } + for (const rel of missing) printError(`missing: ${rel}`); + for (const rel of stale) printError(`out of date: ${rel}`); + console.log(''); + printError( + 'Translation bundles have drifted from the schema. Regenerate and commit:\n' + + ` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== flags['default-locale']).join(',')} ` + + `--fill=${flags.fill} --out=${flags.out}`.replace(/\s+/g, ' '), + ); + process.exit(1); + } + + fs.mkdirSync(outDir, { recursive: true }); + let written = 0; + for (const { file, content, keys } of emitted) { + fs.writeFileSync(file, content, 'utf8'); + written += 1; + printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${keys} keys)`); + } if (!anyMetadataForms) { printInfo('(no metadataForms keys discovered for these locales)'); } diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index c5ec3d181a..24e065bea5 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -12,6 +12,13 @@ * --fill=default \ * --out=packages/platform-objects/src/apps/translations * + * From the repo root that is `pnpm i18n:extract`, and `pnpm check:i18n` is the + * same run with `--check`: it writes nothing and fails if the committed + * bundles differ from a fresh extract. CI runs the latter (lint.yml), so a + * schema change that adds, removes or renames a label now fails the build + * instead of silently rotting the bundles until someone re-runs this by hand + * (#3670). + * * The config is **build-time only** — it is not deployed and not used at * runtime. The Setup App still ships its own bundle via plugin-auth. * From 4c84f337ec67dcd827c3e700bf3f77ef10c0e4eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:18:15 +0000 Subject: [PATCH 2/2] docs(i18n): document the extract --check freshness gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs and the i18n skill both presented `os i18n check` as *the* CI gate. It is the coverage gate — it answers 'are the strings translated?'. For anyone committing generated bundles that leaves the other half unguarded: renaming a label, adding an object, or removing a spec key keeps coverage at 100% while the bundles quietly go stale. Documents the two as a pair, in both surfaces, since the skill is what ships to third parties via `npx skills add`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H --- content/docs/ui/translations.mdx | 16 ++++++++++++++++ skills/objectstack-i18n/SKILL.md | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 5912231eef..ccd9ab1e41 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -141,6 +141,9 @@ npx os i18n extract --locales zh-CN --fill todo --out src/translations # Report coverage; fail the build when it slips npx os i18n check --strict --threshold 95 + +# Fail if the committed bundles have fallen behind the schema +npx os i18n extract --locales zh-CN --fill todo --out src/translations --check ``` `os i18n check` exits non-zero on violations, so it works as a CI gate. @@ -148,6 +151,19 @@ A missing string in the **default** locale is an error; missing strings in other locales are warnings until you set `--strict` / `--threshold`. The Todo example ships a completeness test alongside its bundles — worth copying. +The two gates answer different questions, and you want both. `os i18n check` +asks *are the strings translated?* — a coverage number about human work. +`os i18n extract --check` asks *are the generated bundles still what the schema +produces?* — a freshness check about machine output. Renaming a field's label, +adding an object, or removing a spec key leaves coverage at 100% while the +bundles quietly go stale, which is exactly how this repo's own bundles ended up +carrying translations for keys the schema had deleted (#3670). + +`--check` writes nothing: it re-renders and diffs against `--out`, naming each +stale file and printing the regenerate command. It runs in the same **merge +mode** as a normal extract, so it never asks anyone to re-translate — an +up-to-date bundle re-extracts byte-identically. + ## Current boundaries Honest limits worth knowing before you plan around them: diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index dd0cf7076c..d9f85faae2 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -398,6 +398,32 @@ locale are errors; `--strict` promotes non-default gaps to errors and `--show-keys` lists every missing key. `os lint --i18n-strict` folds the same gate into linting. +### `os i18n extract --check` — freshness, not coverage + +If you commit **generated** bundles (`*.generated.ts` produced by +`os i18n extract`), coverage is only half the gate: + +```bash +os i18n extract --locales=zh-CN,ja-JP --fill=default \ + --out=src/translations --check +``` + +`--check` writes nothing. It re-renders what a real extract would produce and +fails if that differs from what is committed in `--out`, naming each stale or +missing file and printing the regenerate command. + +**Use both gates — they answer different questions.** `os i18n check` asks *are +the strings translated?* (coverage: human work). `extract --check` asks *are the +generated bundles still what the schema produces?* (freshness: machine output). +Renaming a label, adding an object, or removing a spec key leaves coverage at +100% while the bundles quietly go stale — which is exactly how the platform's +own bundles ended up carrying translations for keys the schema had already +deleted, plus fields with no entry in any locale. + +It runs in the same **merge mode** as a normal extract, so it never asks for +re-translation: an up-to-date bundle re-extracts byte-identically. Requires +`--out` — there is nothing to compare against without it. + ### Diff & Coverage Schemas The spec models coverage results for tooling: `TranslationCoverageResult`