Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/i18n-extract-check-flag.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |
Expand Down
16 changes: 16 additions & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,29 @@ 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.
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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 55 additions & 18 deletions packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -153,6 +157,10 @@ export default class I18nExtract extends Command {
}
console.log('');

if (flags.check && !flags.out) {
throw new Error('--check needs --out=<dir> — 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;
Expand All @@ -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)');
}
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
26 changes: 26 additions & 0 deletions skills/objectstack-i18n/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config> --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`
Expand Down
Loading