Skip to content

Commit 31468fc

Browse files
os-zhuangclaude
andauthored
feat(cli): gate generated translation bundles with os i18n extract --check (#3683)
* feat(cli): gate generated translation bundles with `os i18n extract --check` 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H * docs(i18n): document the extract --check freshness gate 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c7e246 commit 31468fc

8 files changed

Lines changed: 153 additions & 18 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os i18n extract --check` — fail instead of writing when translation bundles have drifted
6+
7+
Generated translation bundles had no freshness gate, so they rotted silently
8+
until someone happened to re-run the extractor by hand. #3670 found three
9+
distinct drifts sitting in the committed bundles at once: translations left
10+
behind for schema keys that had been REMOVED, keys the schema had GAINED with
11+
no entry at all, and an object whose labels were committed as empty strings
12+
(which renders blank rather than falling back to anything readable).
13+
14+
`--check` writes nothing and exits non-zero when a fresh extract differs from
15+
what is committed in `--out`, listing each stale or missing file and printing
16+
the exact regenerate command. It runs the identical render path as a real
17+
extract — both branches iterate the same rendered set — so the check can never
18+
disagree with what writing would produce.
19+
20+
It runs in **merge mode** like any other extract, so it never asks anyone to
21+
re-translate: an up-to-date bundle re-extracts byte-identically. Requires
22+
`--out`, since there is nothing to compare against without it.
23+
24+
In this repo it is wired up as `pnpm check:i18n` and gated in CI, but the flag
25+
is on the CLI, so any consumer shipping generated bundles can gate them the
26+
same way.

.github/workflows/lint.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,26 @@ jobs:
247247
- name: Check skills TypeScript examples compile
248248
run: pnpm --filter @objectstack/spec run check:skill-examples
249249

250+
# Same anti-drift class as the gates above, for the generated translation
251+
# bundles in packages/platform-objects/src/apps/translations/. Nothing
252+
# regenerated them either, so they rotted three ways at once (#3670):
253+
# translations left behind for keys the schema had REMOVED (`enable.trash`
254+
# / `enable.mru`, #2377; agent `visibility`, #1901), keys the schema had
255+
# GAINED with no entry at all (`summaryOperations.*`, the ADR-0105 D8
256+
# invitation-placement fields, and the better-auth 1.7 columns from
257+
# #3647), and `sys_migration` sitting on EMPTY STRINGS in ja-JP/es-ES —
258+
# which renders blank rather than falling back to anything readable.
259+
#
260+
# Every one of those was found by a human happening to re-run the
261+
# extractor. This turns the next one into a red build. Runs in merge
262+
# mode, so it never asks anyone to re-translate: a fresh extract of an
263+
# up-to-date bundle is byte-identical to what is committed.
264+
#
265+
# Reads the built @objectstack/spec dist through the extract config, so
266+
# it belongs after the build step with the other consumer gates.
267+
- name: Check generated translation bundles are in sync with the schema
268+
run: pnpm check:i18n
269+
250270
# Seed the shared Turbo cache from main only (see the restore step above).
251271
- name: Save Turbo cache (main only)
252272
if: always() && github.event_name == 'push'

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d
188188
| Path | Type | Rule |
189189
|:---|:---|:---|
190190
| `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. |
191+
| `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. |
191192
| `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. |
192193
| `content/docs/concepts/` | hand-written ||
193194
| `content/docs/getting-started/` | hand-written ||

content/docs/ui/translations.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,29 @@ npx os i18n extract --locales zh-CN --fill todo --out src/translations
141141
142142
# Report coverage; fail the build when it slips
143143
npx os i18n check --strict --threshold 95
144+
145+
# Fail if the committed bundles have fallen behind the schema
146+
npx os i18n extract --locales zh-CN --fill todo --out src/translations --check
144147
```
145148

146149
`os i18n check` exits non-zero on violations, so it works as a CI gate.
147150
A missing string in the **default** locale is an error; missing strings in
148151
other locales are warnings until you set `--strict` / `--threshold`. The Todo
149152
example ships a completeness test alongside its bundlesworth copying.
150153

154+
The two gates answer different questions, and you want both. `os i18n check`
155+
asks *are the strings translated?*a coverage number about human work.
156+
`os i18n extract --check` asks *are the generated bundles still what the schema
157+
produces?*a freshness check about machine output. Renaming a field's label,
158+
adding an object, or removing a spec key leaves coverage at 100% while the
159+
bundles quietly go stale, which is exactly how this repo's own bundles ended up
160+
carrying translations for keys the schema had deleted (#3670).
161+
162+
`--check` writes nothing: it re-renders and diffs against `--out`, naming each
163+
stale file and printing the regenerate command. It runs in the same **merge
164+
mode** as a normal extract, so it never asks anyone to re-translatean
165+
up-to-date bundle re-extracts byte-identically.
166+
151167
## Current boundaries
152168

153169
Honest limits worth knowing before you plan around them:

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh",
2727
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
2828
"lint": "eslint . --no-inline-config",
29+
"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",
30+
"check:i18n": "pnpm i18n:extract --check",
2931
"check:nul-bytes": "node scripts/check-nul-bytes.mjs",
3032
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
3133
"check:role-word": "node scripts/check-role-word.mjs",

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

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export default class I18nExtract extends Command {
8585
description: 'Print to stdout instead of writing to --out',
8686
default: false,
8787
}),
88+
check: Flags.boolean({
89+
description: 'Write nothing; fail if the committed bundles in --out differ from a fresh extract',
90+
default: false,
91+
}),
8892
};
8993

9094
async run(): Promise<void> {
@@ -153,6 +157,10 @@ export default class I18nExtract extends Command {
153157
}
154158
console.log('');
155159

160+
if (flags.check && !flags.out) {
161+
throw new Error('--check needs --out=<dir> — it compares a fresh extract against the bundles committed there.');
162+
}
163+
156164
if (flags['dry-run'] || !flags.out) {
157165
for (const locale of localesEmitted) {
158166
if (result.counts[locale] === 0 && metadataFormsCounts[locale] === 0) continue;
@@ -174,30 +182,59 @@ export default class I18nExtract extends Command {
174182
}
175183

176184
const outDir = path.resolve(process.cwd(), flags.out);
177-
fs.mkdirSync(outDir, { recursive: true });
178-
let written = 0;
185+
186+
// Every file a normal run would emit, paired with its rendered content.
187+
// Both branches below iterate this, so `--check` can never diverge from
188+
// what a real extract writes.
189+
const emitted: Array<{ file: string; content: string; keys: number }> = [];
179190
for (const locale of localesEmitted) {
180191
if (result.counts[locale] > 0) {
181-
const file = path.join(outDir, `${locale}.objects.generated.ts`);
182-
fs.writeFileSync(
183-
file,
184-
renderTranslationModule(result.bundles[locale], { locale, objectsOnly }),
185-
'utf8',
186-
);
187-
written += 1;
188-
printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${result.counts[locale]} keys)`);
192+
emitted.push({
193+
file: path.join(outDir, `${locale}.objects.generated.ts`),
194+
content: renderTranslationModule(result.bundles[locale], { locale, objectsOnly }),
195+
keys: result.counts[locale],
196+
});
189197
}
190198
if (metadataFormsCounts[locale] > 0) {
191-
const file = path.join(outDir, `${locale}.metadata-forms.generated.ts`);
192-
fs.writeFileSync(
193-
file,
194-
renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }),
195-
'utf8',
196-
);
197-
written += 1;
198-
printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${metadataFormsCounts[locale]} keys)`);
199+
emitted.push({
200+
file: path.join(outDir, `${locale}.metadata-forms.generated.ts`),
201+
content: renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }),
202+
keys: metadataFormsCounts[locale],
203+
});
199204
}
200205
}
206+
207+
if (flags.check) {
208+
const stale: string[] = [];
209+
const missing: string[] = [];
210+
for (const { file, content } of emitted) {
211+
const rel = path.relative(process.cwd(), file);
212+
if (!fs.existsSync(file)) missing.push(rel);
213+
else if (fs.readFileSync(file, 'utf8') !== content) stale.push(rel);
214+
}
215+
if (missing.length === 0 && stale.length === 0) {
216+
console.log('');
217+
printSuccess(`${emitted.length} bundle(s) are in sync with the schema ${chalk.dim(`(${timer.display()})`)}`);
218+
return;
219+
}
220+
for (const rel of missing) printError(`missing: ${rel}`);
221+
for (const rel of stale) printError(`out of date: ${rel}`);
222+
console.log('');
223+
printError(
224+
'Translation bundles have drifted from the schema. Regenerate and commit:\n' +
225+
` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== flags['default-locale']).join(',')} ` +
226+
`--fill=${flags.fill} --out=${flags.out}`.replace(/\s+/g, ' '),
227+
);
228+
process.exit(1);
229+
}
230+
231+
fs.mkdirSync(outDir, { recursive: true });
232+
let written = 0;
233+
for (const { file, content, keys } of emitted) {
234+
fs.writeFileSync(file, content, 'utf8');
235+
written += 1;
236+
printInfo(`Wrote ${chalk.white(path.relative(process.cwd(), file))} (${keys} keys)`);
237+
}
201238
if (!anyMetadataForms) {
202239
printInfo('(no metadataForms keys discovered for these locales)');
203240
}

packages/platform-objects/scripts/i18n-extract.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
* --fill=default \
1313
* --out=packages/platform-objects/src/apps/translations
1414
*
15+
* From the repo root that is `pnpm i18n:extract`, and `pnpm check:i18n` is the
16+
* same run with `--check`: it writes nothing and fails if the committed
17+
* bundles differ from a fresh extract. CI runs the latter (lint.yml), so a
18+
* schema change that adds, removes or renames a label now fails the build
19+
* instead of silently rotting the bundles until someone re-runs this by hand
20+
* (#3670).
21+
*
1522
* The config is **build-time only** — it is not deployed and not used at
1623
* runtime. The Setup App still ships its own bundle via plugin-auth.
1724
*

skills/objectstack-i18n/SKILL.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,32 @@ locale are errors; `--strict` promotes non-default gaps to errors and
398398
`--show-keys` lists every missing key. `os lint --i18n-strict` folds the same
399399
gate into linting.
400400

401+
### `os i18n extract --check` — freshness, not coverage
402+
403+
If you commit **generated** bundles (`*.generated.ts` produced by
404+
`os i18n extract`), coverage is only half the gate:
405+
406+
```bash
407+
os i18n extract <config> --locales=zh-CN,ja-JP --fill=default \
408+
--out=src/translations --check
409+
```
410+
411+
`--check` writes nothing. It re-renders what a real extract would produce and
412+
fails if that differs from what is committed in `--out`, naming each stale or
413+
missing file and printing the regenerate command.
414+
415+
**Use both gates — they answer different questions.** `os i18n check` asks *are
416+
the strings translated?* (coverage: human work). `extract --check` asks *are the
417+
generated bundles still what the schema produces?* (freshness: machine output).
418+
Renaming a label, adding an object, or removing a spec key leaves coverage at
419+
100% while the bundles quietly go stale — which is exactly how the platform's
420+
own bundles ended up carrying translations for keys the schema had already
421+
deleted, plus fields with no entry in any locale.
422+
423+
It runs in the same **merge mode** as a normal extract, so it never asks for
424+
re-translation: an up-to-date bundle re-extracts byte-identically. Requires
425+
`--out` — there is nothing to compare against without it.
426+
401427
### Diff & Coverage Schemas
402428

403429
The spec models coverage results for tooling: `TranslationCoverageResult`

0 commit comments

Comments
 (0)