diff --git a/.changeset/i18n-coverage-ratchet.md b/.changeset/i18n-coverage-ratchet.md new file mode 100644 index 0000000000..d381adcb95 --- /dev/null +++ b/.changeset/i18n-coverage-ratchet.md @@ -0,0 +1,26 @@ +--- +--- + +ci: ratchet the examples' untranslated declared labels so the #3370 gate can actually fail + +Releases nothing — root scripts and CI config only. + +#3370 taught `os lint` to gate the whole declared surface, but seeing a problem +and failing on it are different things. `os lint --i18n-strict` — the honest +"these locales must be complete" gate — reports 97 / 212 / 456 errors on +app-crm / app-todo / app-showcase today, because those examples declare +`i18n.supportedLocales` and then leave a few hundred declared strings +untranslated. Switching it on as-is would paint CI red on day one and get +switched back off, which is how a gate stops being a gate. + +`scripts/check-i18n-coverage.mjs` freezes that debt in +`scripts/i18n-coverage-baseline.json` and fails the build when a count grows — +i.e. when someone declares a label and does not translate it for a locale the +example claims to support. It follows `scripts/check-role-word.mjs`: `--update` +ratchets, and an *improvement* also fails, so the baseline can only go down. +Counts ignore severity (that moves with `--i18n-strict`) and exclude the +platform metadata-form baseline (owned and translated by platform-objects), so +what is tracked is the example's own declared surface. + +Verified the gate bites: adding one untranslated action to app-crm takes it +97 → 99 and exits 1. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 53c3e95518..2dd169ce1a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -267,6 +267,19 @@ jobs: - name: Check generated translation bundles are in sync with the schema run: pnpm check:i18n + # Ratchet on the OTHER i18n question. The step above asks "are the + # generated bundles still what the schema produces?"; this one asks "did + # anyone declare a new label and not translate it?" — the gap #3370 closed + # in `os lint` but which had ~765 pre-existing misses across the examples, + # so `--i18n-strict` cannot simply be switched on without painting CI red + # and getting switched back off. The debt is frozen in + # scripts/i18n-coverage-baseline.json; growth fails the build. + # + # Runs the built CLI over each example config, so it also belongs after + # the build step. + - name: Check no new untranslated declared labels + run: pnpm check:i18n-coverage + # 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/package.json b/package.json index 924d9c551b..6247963c5c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "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:i18n-coverage": "node scripts/check-i18n-coverage.mjs", "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/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs new file mode 100644 index 0000000000..3962e10d51 --- /dev/null +++ b/scripts/check-i18n-coverage.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// check-i18n-coverage — declared-label translation ratchet for the bundled examples. +// +// #3370 made `os lint` gate the WHOLE declared surface (inline object actions, +// action params / resultDialog, listViews, apps / dashboards / pages), not just +// object and field labels. That surfaced real pre-existing debt: the examples +// declare `i18n.supportedLocales: ['en', 'zh-CN', …]` and then leave a few +// hundred declared strings untranslated. +// +// So `os lint --i18n-strict` — the honest "these locales must be complete" +// gate — reports ~100-450 errors per example today. Turning it on as-is would +// paint CI red on day one and get switched back off, which is how a gate stops +// being a gate. This is the shippable middle: the debt is FROZEN, and the build +// fails the moment it grows. +// +// Mirrors scripts/check-role-word.mjs. Fails when: +// • an example config is not in the baseline (translate it, or ratchet it in), or +// • a baselined count INCREASES — a newly untranslated declared string, or +// • a baselined count DECREASED / the example vanished (improvement!) — +// run with --update to ratchet down and commit the baseline. +// +// node scripts/check-i18n-coverage.mjs [--update] +// +// Counts only what `os lint` shows a user: the platform metadata-form baseline +// is folded away (it is owned and translated by platform-objects), so this +// tracks the example's OWN declared surface. Severity is ignored on purpose — +// warning-vs-error moves with --i18n-strict, but the SET of untranslated keys +// does not, and that set is what must not grow. +// +// Requires the workspace build (it runs the built CLI), so it belongs after the +// build step with the other consumer gates. +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const EXAMPLES_DIR = 'examples'; +const BASELINE_PATH = 'scripts/i18n-coverage-baseline.json'; +const CLI = 'packages/cli/bin/run.js'; + +const update = process.argv.includes('--update'); + +/** Every bundled example that has a stack config. */ +function discoverExamples() { + if (!existsSync(EXAMPLES_DIR)) return []; + return readdirSync(EXAMPLES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => join(EXAMPLES_DIR, e.name, 'objectstack.config.ts')) + .filter((p) => existsSync(p)) + .sort(); +} + +/** Untranslated declared strings `os lint` would show for one config. */ +function countI18nIssues(configPath) { + let stdout; + try { + stdout = execFileSync(process.execPath, [CLI, 'lint', configPath, '--json'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + // `os lint` exits non-zero when the config has errors of any kind; the JSON + // payload is still on stdout and is what we want. A genuinely broken run + // (no stdout) is a hard failure — never silently a zero. + stdout = err.stdout ?? ''; + if (!stdout.trim()) { + throw new Error(`os lint produced no output for ${configPath}: ${err.stderr || err.message}`); + } + } + const report = JSON.parse(stdout); + if (report.error) throw new Error(`os lint failed for ${configPath}: ${report.error}`); + const issues = report.issues ?? []; + return issues.filter((i) => typeof i.rule === 'string' && i.rule.startsWith('i18n/')).length; +} + +const current = {}; +for (const configPath of discoverExamples()) { + current[configPath] = countI18nIssues(configPath); +} + +if (update) { + writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); + console.log(`i18n coverage baseline updated: ${Object.keys(current).length} example(s).`); + process.exit(0); +} + +const baseline = existsSync(BASELINE_PATH) ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) : {}; + +const errors = []; +for (const [file, count] of Object.entries(current)) { + const allowed = baseline[file]; + if (allowed === undefined) { + errors.push( + `${file}: new example is not baselined (${count} untranslated declared string(s)). ` + + `Translate them, or run \`node scripts/check-i18n-coverage.mjs --update\` to freeze the debt.`, + ); + } else if (count > allowed) { + errors.push( + `${file}: untranslated declared strings grew ${allowed} → ${count}. ` + + `Something declared a label without translating it for a locale this example claims to support ` + + `(see \`i18n.supportedLocales\`). Run \`os i18n extract\` and fill the new keys, or \`os lint ${file}\` to list them.`, + ); + } +} +for (const [file, allowed] of Object.entries(baseline)) { + const now = current[file]; + if (now === undefined) { + errors.push( + `${file}: baselined example is gone (was ${allowed}) — ratchet DOWN: ` + + `run \`node scripts/check-i18n-coverage.mjs --update\` and commit the baseline.`, + ); + } else if (now < allowed) { + errors.push( + `${file}: untranslated declared strings improved ${allowed} → ${now} — ratchet DOWN: ` + + `run \`node scripts/check-i18n-coverage.mjs --update\` and commit the baseline.`, + ); + } +} + +if (errors.length) { + console.error(`check-i18n-coverage: ${errors.length} problem(s)\n`); + for (const e of errors) console.error(' • ' + e); + process.exit(1); +} +const total = Object.values(current).reduce((a, b) => a + b, 0); +console.log( + `check-i18n-coverage: OK (${Object.keys(current).length} example(s), ${total} baselined untranslated string(s), none new).`, +); diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json new file mode 100644 index 0000000000..289c2fcab1 --- /dev/null +++ b/scripts/i18n-coverage-baseline.json @@ -0,0 +1,5 @@ +{ + "examples/app-crm/objectstack.config.ts": 97, + "examples/app-showcase/objectstack.config.ts": 456, + "examples/app-todo/objectstack.config.ts": 212 +}