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-coverage-ratchet.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
127 changes: 127 additions & 0 deletions scripts/check-i18n-coverage.mjs
Original file line numberDiff line numberDiff line change
@@ -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).`,
);
5 changes: 5 additions & 0 deletions scripts/i18n-coverage-baseline.json
Original file line numberDiff line numberDiff line change
@@ -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
}
Loading