From c91cc1a48ccdbe1f57e85c649f05bcd676c077d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:29:01 +0000 Subject: [PATCH 1/2] feat(i18n): gate en.ts against the source labels it restates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/translations/en.ts` restates the English labels that already exist inline in the object, view, app, dashboard and dataset definitions, so the two locale files diff line for line. Nothing checked that the restatement was still true: rename a label in an object file and the bundle keeps serving the old string to every English user, because a bundle entry wins over the source label at runtime. Measured before this change, on this tree: `pnpm validate`, `pnpm lint --i18n-strict` and `pnpm typecheck` are all green with a drifted entry, and green with a missing `en` entry too — `os i18n check` reports `en` at 100 % (expected 1265 / translated 1265) because the source label counts as the translation for the default locale. Coverage asks whether a key exists, not whether it still says what the metadata says. `scripts/check-i18n-source.mjs` asks the second question. It reads both sides out of `dist/objectstack.json` and fails on a drifted value, a key whose metadata is gone, and a source label the bundle never restates. The source side is collected twice on purpose: `os i18n extract --json` is the platform's own single definition of what is translatable, so a surface the platform adds later arrives here for free; a local resolver covers the view-nested text that walk does not address (a view's default `list`, its `listViews` and its `formViews`). Together they resolve all 507 keys in `en.ts` — every key is compared, none is skipped, and a key shape neither collector knows fails as an orphan rather than passing quietly. `pnpm lint` chains it, so CI and a laptop run it without a fourth command. It runs its own `--self-test` first, on every run: seven assertions that the comparator still reports a mismatch, an orphan and a gap, so the gate proves in CI that it can fail. The real run refuses to report success when either side came back empty, or when the artifact is older than `src/`. Zero findings on this tree today: 507 bundle keys, 507 source labels, 507 compared, 769 platform `metadataForms.*` keys excluded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- .github/workflows/ci.yml | 9 +- AGENTS.md | 1 + CONTRIBUTING.md | 17 +- README.md | 3 +- package.json | 3 +- scripts/check-i18n-source.mjs | 533 ++++++++++++++++++++++++++++++++++ src/translations/en.ts | 26 +- 7 files changed, 575 insertions(+), 17 deletions(-) create mode 100644 scripts/check-i18n-source.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b98b8e0..ef87087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,12 @@ # the same commands, the same scripts in package.json, so a green run here and # a green run on a laptop mean the same thing. Each gate is its own step so a # red run names the gate that failed; `pnpm lint` carries `--i18n-strict`, so -# a translatable key missing its zh-CN string is a failure, not a warning. +# a translatable key missing its zh-CN string is a failure, not a warning, and +# it chains `pnpm check:i18n-source`, so an `en.ts` entry that no longer matches +# the label its metadata declares is a failure too (#63). That script runs its +# own `--self-test` first, on every CI run: the gate proves here, in CI, that it +# still reports a drifted value, an orphaned key and a missing one, so a green +# run means "compared and equal" rather than "compared nothing". # # Nothing here is allowed to skip: `--frozen-lockfile` refuses to resolve a # lockfile that disagrees with package.json instead of quietly rewriting it, @@ -63,7 +68,7 @@ jobs: - name: validate — protocol schema, CEL predicates, widget bindings run: pnpm validate - - name: lint — data-model conventions + zh-CN translation parity + - name: lint — data-model conventions + zh-CN coverage + en.ts source parity run: pnpm lint - name: typecheck diff --git a/AGENTS.md b/AGENTS.md index 05507c7..c315b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ platform operator) defined as typed metadata. Not a framework, not a package — ```bash pnpm validate # protocol schema + CEL predicates (record. existence) + widget bindings pnpm lint # data-model conventions: reserved vocabulary, titles, master-detail, select options + # + zh-CN coverage (--i18n-strict) + en.ts source parity (pnpm check:i18n-source) pnpm typecheck ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b935d7f..10c126d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,7 @@ coding agents working from the same rules. Those rules live in three files; this pnpm install pnpm validate # protocol schema + CEL predicates (record. existence) + widget bindings pnpm lint # data-model conventions + zh-CN translation parity (--i18n-strict) + # + en.ts source parity (pnpm check:i18n-source, runnable on its own) pnpm typecheck ``` @@ -100,9 +101,19 @@ validation messages, action copy, navigation, dashboards, datasets — exists in - `zh-CN` is authored in [`src/translations/zh-CN.ts`](./src/translations/zh-CN.ts), with the vocabulary `DESIGN.md` fixes. Do not machine-translate the domain words. -`pnpm lint` fails when a translatable key is missing in either file; `npx objectstack i18n check --show-keys` -lists exactly which. The Studio's own metadata-form strings are the platform's and are not this -repository's to translate (`lint` hides them unless you pass `--include-platform`). +Two different gates, both inside `pnpm lint`: + +- **Coverage** — `objectstack lint --i18n-strict` fails when a translatable key is missing from a + non-default locale, i.e. from `zh-CN`; `npx objectstack i18n check --show-keys` lists exactly which. It + cannot speak for `en`: it reports the source locale as 100 % translated, because the source label *is* + the translation. +- **Source parity** — `pnpm check:i18n-source` ([`scripts/check-i18n-source.mjs`](./scripts/check-i18n-source.mjs)) + compares every one of `en.ts`'s 507 keys against the label its metadata declares in the built artifact, + and fails on a drifted value, a key whose metadata is gone, or a source label `en.ts` never restates. + Renaming a label without renaming it in `en.ts` is a red gate, not a silent runtime override (#63). + +The Studio's own metadata-form strings are the platform's and are not this repository's to translate +(both gates exclude them; `lint` shows them with `--include-platform`). ## Skills for coding agents diff --git a/README.md b/README.md index 27365f6..01f746d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ asks for `zh-CN` ([`07-…-zh-CN.png`](docs/screenshots/07-hiring-pipeline-kanba - **Three audiences, one metadata set** — platform ops, employer, job seeker each get their own navigation group over the same objects. - **Credentials are first-class** — "licensed to practise, re-certified before expiry" is core, not a plugin. - **Industry-neutral by rule** — no vertical vocabulary in the schema; industries live in seed data only. -- **Two locales** — every label, option, section, message and navigation item exists in `en` and `zh-CN`, and `pnpm lint` fails when one is missing. +- **Two locales** — every label, option, section, message and navigation item exists in `en` and `zh-CN`, and `pnpm lint` fails when one is missing, or when an `en` entry stops matching the label its metadata declares. ## Quick start @@ -160,6 +160,7 @@ Every metadata change is gated, locally and in [CI](./.github/workflows/ci.yml): ```bash pnpm validate # protocol schema + CEL predicates + bindings pnpm lint # data-model conventions (ADR-0090 vocabulary, titles, master-detail) + zh-CN parity + # + en.ts source parity (pnpm check:i18n-source, runnable on its own) pnpm typecheck ``` diff --git a/package.json b/package.json index 2bd44ea..0ad7553 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start": "objectstack start", "build": "objectstack build", "validate": "objectstack validate", - "lint": "objectstack lint --i18n-strict", + "lint": "objectstack lint --i18n-strict && pnpm check:i18n-source", + "check:i18n-source": "objectstack build && node scripts/check-i18n-source.mjs --self-test && node scripts/check-i18n-source.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/check-i18n-source.mjs b/scripts/check-i18n-source.mjs new file mode 100644 index 0000000..75f7808 --- /dev/null +++ b/scripts/check-i18n-source.mjs @@ -0,0 +1,533 @@ +#!/usr/bin/env node +/** + * check-i18n-source — does `src/translations/en.ts` still say what the source says? + * + * ## The hole this closes (#63) + * + * `en` is the SOURCE locale: every inline `label:` in `src/` already is the English + * text, and the runtime falls back to it. `src/translations/en.ts` restates those + * strings anyway, so the two locale files have the same shape and a translator can + * diff them line for line (#62 kept the file for exactly that). The cost is a + * silent-override trap the file's own header names: rename `ats_job.fields.status` + * from "Status" to "Review status" in `job.object.ts` and the bundle keeps serving + * "Status" to every English user, forever — a stale entry WINS over the source + * label at runtime. + * + * Nothing caught it. Measured on this repository before this script existed: + * `pnpm validate`, `pnpm typecheck` and `pnpm lint --i18n-strict` are all green + * with a drifted entry, and green with a MISSING `en` entry too — `os i18n check` + * reports `en` at 100 % coverage (expected 1265 / translated 1265) because the + * source label counts as the translation for the default locale. Coverage cannot + * see this class of bug: it counts whether a key EXISTS, never whether it still + * says what the metadata says. + * + * ## What this checks + * + * Both directions between the bundle and the labels the metadata declares: + * + * mismatch — the key exists on both sides and the strings differ (the trap) + * orphan — the bundle has a key the metadata declares no text for (a deleted + * or renamed field leaves its entry behind, overriding nothing a + * reader will ever see, and still diffed by a translator) + * missing — the metadata declares text the bundle never restates (a new field + * lands, `zh-CN` gets its entry and `en.ts` does not, and the + * side-by-side the file exists for is broken) + * + * All three are drift and all three fail. `missing` is in scope precisely because + * no other gate covers it: `--i18n-strict` gates the NON-default locales. + * + * ## Where the two sides come from + * + * Both are in `dist/objectstack.json`, so the comparison needs no TypeScript + * parsing and reads what actually ships: + * + * bundle side — `translations[].`, the parsed `en.ts`. + * source side — two collectors, deliberately not one: + * + * 1. `os i18n extract --json` for this stack. That command is built on + * `collectExpectedEntries`, which the CLI documents as the SINGLE + * definition of what is translatable at all (`os lint`'s coverage gate + * consumes the same walk). Using it means a surface the platform adds + * later — a new key face, a new metadata kind — arrives here for free + * instead of waiting for someone to notice this file is behind. + * 2. A local resolver for the view-nested text the platform walk does not + * address: a view document's default `list`, its `listViews` and its + * `formViews`. `en.ts` restates 25 of these; without collector 2 they + * would be 25 keys compared against nothing. + * + * `metadataForms.*` is dropped from collector 1 — Studio metadata-form copy the + * platform packages own and ship, the bucket `pnpm lint` reports as + * "platform built-ins: 773 i18n issue(s) hidden" (#60). Not this app's strings, + * and not in `en.ts`. The count printed below is 769, not 773: 773 is the number + * of metadata-form ADDRESSES the walk records, 769 of them carry a string the + * extract can seed. Both numbers are the platform's, and neither is compared. + * + * ## Why an unknown key is a failure and never a skip + * + * A gate whose coverage is invisible is the defect class this card closes, so this + * script has no skip path. Every bundle key is either compared or reported as an + * `orphan`; a key shape neither collector knows lands in `orphan` with its own + * count, loudly, rather than passing quietly. Two more guards keep a green run + * meaningful: `--self-test` proves the comparator still reports a mismatch, an + * orphan and a missing key on synthetic input (it runs first, in the same npm + * script and in CI), and the real run refuses to report success when either side + * came back empty — a build that emitted no translations, or an extract that + * returned nothing, is a failure here, not a pass. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const ARTIFACT = join(ROOT, 'dist', 'objectstack.json'); +const CLI = join(ROOT, 'node_modules', '.bin', 'objectstack'); + +/** The platform-owned bucket: Studio metadata-form copy, shipped by the platform packages. */ +const PLATFORM_GROUP = 'metadataForms'; + +// ─────────────────────────────────────────────────────────────────────────────── +// Small helpers +// ─────────────────────────────────────────────────────────────────────────────── + +const ESC = '\u001b'; +const paint = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`; +const bold = (s) => paint('1', s); +const red = (s) => paint('31', s); +const green = (s) => paint('32', s); +const dim = (s) => paint('2', s); + +class CheckError extends Error {} + +/** Fail with a located, corrective message rather than a stack trace. */ +function fail(message) { + throw new CheckError(message); +} + +/** + * Flatten a translation tree into dotted keys. + * + * Every leaf must be a string: `TranslationData` has no array or number leaves, + * so one is a shape this script does not understand and must not silently drop. + */ +function flatten(tree, prefix, into = new Map()) { + for (const [key, value] of Object.entries(tree)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value === null || value === undefined) continue; + if (typeof value === 'string') { + into.set(path, value); + } else if (typeof value === 'object' && !Array.isArray(value)) { + flatten(value, path, into); + } else { + fail(`translation leaf "${path}" is a ${Array.isArray(value) ? 'array' : typeof value}, not a string — ` + + 'this script only understands string leaves, and refuses to skip one it cannot compare.'); + } + } + return into; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// The comparator — the part `--self-test` exercises +// ─────────────────────────────────────────────────────────────────────────────── + +/** + * Compare a bundle against the source labels. + * + * Pure: `bundle` and `source` are `Map` of dotted key to string, `origins` is a + * `Map` naming where each source string was read from (used only in the report). + * Returns the three finding lists, each sorted by key. + */ +export function compare(bundle, source, origins = new Map()) { + const mismatches = []; + const orphans = []; + const missing = []; + + for (const [key, bundleValue] of bundle) { + if (!source.has(key)) { + orphans.push({ key, bundleValue }); + continue; + } + const sourceValue = source.get(key); + if (sourceValue !== bundleValue) { + mismatches.push({ key, bundleValue, sourceValue, origin: origins.get(key) }); + } + } + for (const [key, sourceValue] of source) { + if (!bundle.has(key)) missing.push({ key, sourceValue, origin: origins.get(key) }); + } + + const byKey = (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0); + mismatches.sort(byKey); + orphans.sort(byKey); + missing.sort(byKey); + + const compared = [...bundle.keys()].filter((key) => source.has(key)).length; + return { mismatches, orphans, missing, compared }; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Collector 1 — the platform's own definition of the translatable surface +// ─────────────────────────────────────────────────────────────────────────────── + +/** + * Run `os i18n extract --json` and return its default-locale skeleton, minus the + * platform-owned group. + * + * `--no-merge` is load-bearing: without it the extractor omits every key the + * bundle already has, which is every key this check exists to compare. + * `--no-objects-only` is load-bearing too: the default emits only the objects + * subtree, and `en.ts` also carries apps, dashboards and datasets. + */ +function collectPlatformSurface(defaultLocale) { + if (!existsSync(CLI)) { + fail(`the ObjectStack CLI is not installed at ${CLI} — run \`pnpm install\` first.`); + } + let raw; + try { + raw = execFileSync( + CLI, + ['i18n', 'extract', '--json', '--no-merge', '--no-objects-only', + `--locales=${defaultLocale}`, `--default-locale=${defaultLocale}`], + { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (error) { + const stderr = String(error.stderr ?? '').trim(); + fail(`\`os i18n extract --json\` failed (exit ${error.status ?? '?'}).\n${stderr || error.message}`); + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + fail('`os i18n extract --json` did not print JSON. This check reads the platform\'s own translatable ' + + 'surface from that command; it will not fall back to a partial surface of its own.'); + } + + const skeleton = parsed?.bundles?.[defaultLocale]; + if (!skeleton || typeof skeleton !== 'object') { + fail(`\`os i18n extract --json\` returned no "${defaultLocale}" bundle (bundles: ` + + `${Object.keys(parsed?.bundles ?? {}).join(', ') || 'none'}). The extract shape changed; teach this ` + + 'script the new one rather than letting it compare against nothing.'); + } + + const appOnly = Object.fromEntries( + Object.entries(skeleton).filter(([group]) => group !== PLATFORM_GROUP), + ); + const platformGroup = skeleton[PLATFORM_GROUP]; + const platformKeys = platformGroup ? flatten({ [PLATFORM_GROUP]: platformGroup }, '').size : 0; + return { source: flatten(appOnly, ''), platformKeys }; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Collector 2 — view-nested text the platform walk does not address +// ─────────────────────────────────────────────────────────────────────────────── + +/** + * Resolve the `objects.OBJECT._views.NAME.label` / `.description` keys whose text + * lives INSIDE a view document rather than on it: + * + * view.list the default list view (`_views.all.*` here, since + * every view document in this app names its list "all") + * view.listViews[NAME] the saved list views (pipeline, inbox_*, mine, …) + * view.formViews[NAME] the form views; a form view spells its heading + * `title`, and the bundle key face spells it `label` + * + * A name that resolves to two different strings within one object is an ambiguity, + * not a coin flip: it is reported and fails. + */ +export function collectViewNestedSurface(views) { + const source = new Map(); + const origins = new Map(); + const ambiguous = []; + + const put = (objectName, viewName, key, value, origin) => { + if (typeof value !== 'string' || value.length === 0) return; + const path = `objects.${objectName}._views.${viewName}.${key}`; + const seen = source.get(path); + if (seen !== undefined && seen !== value) { + ambiguous.push({ + key: path, + a: `${origins.get(path)} = ${JSON.stringify(seen)}`, + b: `${origin} = ${JSON.stringify(value)}`, + }); + return; + } + source.set(path, value); + origins.set(path, origin); + }; + + for (const view of views ?? []) { + const objectName = view?.object; + if (!objectName || !view?.name) continue; + + const list = view.list; + if (list && typeof list === 'object' && typeof list.name === 'string') { + put(objectName, list.name, 'label', list.label, `views.${view.name}.list.label`); + put(objectName, list.name, 'description', list.description, `views.${view.name}.list.description`); + } + for (const [name, listView] of Object.entries(view.listViews ?? {})) { + put(objectName, name, 'label', listView?.label, `views.${view.name}.listViews.${name}.label`); + put(objectName, name, 'description', listView?.description, `views.${view.name}.listViews.${name}.description`); + } + for (const [name, formView] of Object.entries(view.formViews ?? {})) { + put(objectName, name, 'label', formView?.title, `views.${view.name}.formViews.${name}.title`); + put(objectName, name, 'description', formView?.description, `views.${view.name}.formViews.${name}.description`); + } + } + + return { source, origins, ambiguous }; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Freshness — a stale artifact is a check that measured yesterday's tree +// ─────────────────────────────────────────────────────────────────────────────── + +function newestSourceMtime() { + let newest = 0; + const visit = (path) => { + const stat = statSync(path); + if (stat.isDirectory()) { + for (const entry of readdirSync(path)) visit(join(path, entry)); + } else if (stat.mtimeMs > newest) { + newest = stat.mtimeMs; + } + }; + visit(join(ROOT, 'src')); + visit(join(ROOT, 'objectstack.config.ts')); + return newest; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Report +// ─────────────────────────────────────────────────────────────────────────────── + +function printFindings(title, rows, render) { + console.log(''); + console.log(` ${red(bold(`${title} (${rows.length})`))}`); + for (const row of rows) { + for (const line of render(row)) console.log(` ${line}`); + } +} + +function run() { + console.log(''); + console.log(bold('◆ i18n source parity')); + console.log('────────────────────────────────────────'); + + if (!existsSync(ARTIFACT)) { + fail('no build artifact at dist/objectstack.json — this check reads both sides from it.\n' + + ' Run `pnpm check:i18n-source` (it builds first) or `npx objectstack build`.'); + } + const artifactMtime = statSync(ARTIFACT).mtimeMs; + if (newestSourceMtime() > artifactMtime) { + fail('dist/objectstack.json is older than src/ — it would be compared against a tree that has moved.\n' + + ' Run `pnpm check:i18n-source` (it builds first) or `npx objectstack build`.'); + } + + const artifact = JSON.parse(readFileSync(ARTIFACT, 'utf8')); + const defaultLocale = artifact?.i18n?.defaultLocale ?? 'en'; + + const trees = (artifact.translations ?? []).map((entry) => entry?.[defaultLocale]).filter(Boolean); + if (trees.length === 0) { + fail(`the artifact carries no "${defaultLocale}" translation bundle. Either src/translations/index.ts ` + + 'stopped registering it, or the source locale changed — both make this comparison meaningless.'); + } + const bundle = new Map(); + for (const tree of trees) for (const [key, value] of flatten(tree, '')) bundle.set(key, value); + + const { source: platformSource, platformKeys } = collectPlatformSurface(defaultLocale); + const { source: viewSource, origins: viewOrigins, ambiguous } = collectViewNestedSurface(artifact.views); + + if (ambiguous.length > 0) { + printFindings('ambiguous view-nested keys', ambiguous, (row) => [bold(row.key), ` ${row.a}`, ` ${row.b}`]); + fail('one bundle key resolves to two different source strings — the resolver cannot choose, and will ' + + 'not guess. Rename one of the nested views so each key names one string.'); + } + + const source = new Map(platformSource); + const origins = new Map(); + for (const key of platformSource.keys()) origins.set(key, 'os i18n extract'); + const conflicts = []; + for (const [key, value] of viewSource) { + if (source.has(key) && source.get(key) !== value) { + conflicts.push({ + key, + a: `os i18n extract = ${JSON.stringify(source.get(key))}`, + b: `${viewOrigins.get(key)} = ${JSON.stringify(value)}`, + }); + continue; + } + source.set(key, value); + origins.set(key, viewOrigins.get(key)); + } + if (conflicts.length > 0) { + printFindings('collector conflicts', conflicts, (row) => [bold(row.key), ` ${row.a}`, ` ${row.b}`]); + fail('the platform walk and the view-nested resolver disagree about what the source says. One of them ' + + 'is wrong; this script will not pick a winner.'); + } + + // Vacuity guard: a green run must mean "compared and equal", never "compared nothing". + if (bundle.size === 0) { + fail(`the "${defaultLocale}" bundle is empty — there is nothing to check, which is not a pass.`); + } + if (source.size === 0) { + fail('no source labels were collected — there is nothing to check against, which is not a pass.'); + } + + const { mismatches, orphans, missing, compared } = compare(bundle, source, origins); + if (compared === 0) { + fail(`${bundle.size} bundle keys and ${source.size} source labels share no key at all. The two key ` + + 'spellings have diverged; this is a broken check, not a clean tree.'); + } + + console.log(` bundle keys (src/translations/${defaultLocale}.ts) ${String(bundle.size).padStart(5)}`); + console.log(` source labels collected ${String(source.size).padStart(5)} ` + + dim(`(${platformSource.size} os i18n extract + ${viewSource.size} view-nested)`)); + console.log(` compared, key present on both sides ${String(compared).padStart(5)}`); + console.log(` excluded: platform built-in keys ${String(platformKeys).padStart(5)} ` + + dim(`(${PLATFORM_GROUP}.* — Studio copy the platform packages ship)`)); + + if (mismatches.length === 0 && orphans.length === 0 && missing.length === 0) { + console.log(''); + console.log(` ${green('✓')} every bundle key restates the label its metadata declares`); + console.log(''); + return 0; + } + + if (mismatches.length > 0) { + printFindings('drifted — the bundle overrides the source label', mismatches, (row) => [ + bold(row.key), + ` bundle (${defaultLocale}.ts) ${JSON.stringify(row.bundleValue)}`, + ` source${' '.repeat(defaultLocale.length + 8)}${JSON.stringify(row.sourceValue)}` + + (row.origin ? dim(` [${row.origin}]`) : ''), + ]); + } + if (orphans.length > 0) { + printFindings('orphaned — no metadata declares text at this key', orphans, (row) => [ + bold(row.key), + ` bundle (${defaultLocale}.ts) ${JSON.stringify(row.bundleValue)}`, + ]); + } + if (missing.length > 0) { + printFindings(`missing — the metadata declares text ${defaultLocale}.ts never restates`, missing, (row) => [ + bold(row.key), + ` source ${JSON.stringify(row.sourceValue)}${row.origin ? dim(` [${row.origin}]`) : ''}`, + ]); + } + + const total = mismatches.length + orphans.length + missing.length; + console.log(''); + console.log(` ${red(bold(`${total} i18n source-parity issue(s)`))}`); + console.log(` ${dim(`src/translations/${defaultLocale}.ts restates the source labels; a stale entry there wins at runtime.`)}`); + console.log(` ${dim('Fix the bundle to match the metadata, or fix the metadata — whichever is the mistake.')}`); + console.log(''); + return 1; +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Self-test — proof the comparator can still fail, run before every real run +// ─────────────────────────────────────────────────────────────────────────────── + +function selfTest() { + const failures = []; + let assertions = 0; + const check = (name, condition, detail) => { + assertions += 1; + if (!condition) failures.push(`${name}: ${detail}`); + }; + + const clean = compare(new Map([['a.b', 'X']]), new Map([['a.b', 'X']])); + check('a clean tree reports nothing', + clean.mismatches.length === 0 && clean.orphans.length === 0 && clean.missing.length === 0 + && clean.compared === 1, + JSON.stringify(clean)); + + const drift = compare( + new Map([['objects.o.fields.f.label', 'Status']]), + new Map([['objects.o.fields.f.label', 'Review status']]), + ); + check('a drifted value is reported as a mismatch, carrying both strings', + drift.mismatches.length === 1 + && drift.mismatches[0].key === 'objects.o.fields.f.label' + && drift.mismatches[0].bundleValue === 'Status' + && drift.mismatches[0].sourceValue === 'Review status' + && drift.orphans.length === 0 && drift.missing.length === 0, + JSON.stringify(drift)); + + const orphan = compare( + new Map([['objects.o.fields.gone.label', 'Gone']]), + new Map([['objects.o.label', 'O']]), + ); + check('a bundle key with no source label is reported as an orphan', + orphan.orphans.length === 1 && orphan.orphans[0].key === 'objects.o.fields.gone.label' + && orphan.compared === 0, + JSON.stringify(orphan)); + + const gap = compare(new Map(), new Map([['objects.o.fields.new.label', 'New']])); + check('a source label the bundle never restates is reported as missing', + gap.missing.length === 1 && gap.missing[0].key === 'objects.o.fields.new.label', + JSON.stringify(gap)); + + const nested = collectViewNestedSurface([{ + name: 'v', + object: 'o', + list: { name: 'all', label: 'All Things' }, + listViews: { mine: { label: 'Mine' } }, + formViews: { apply: { title: 'Apply', description: 'Tell us who you are.' }, default: {} }, + }]); + check('the view-nested resolver addresses list, listViews and formViews', + nested.source.get('objects.o._views.all.label') === 'All Things' + && nested.source.get('objects.o._views.mine.label') === 'Mine' + && nested.source.get('objects.o._views.apply.label') === 'Apply' + && nested.source.get('objects.o._views.apply.description') === 'Tell us who you are.' + && !nested.source.has('objects.o._views.default.label') + && nested.ambiguous.length === 0, + JSON.stringify([...nested.source])); + + const clash = collectViewNestedSurface([{ + name: 'v', + object: 'o', + list: { name: 'dup', label: 'One' }, + listViews: { dup: { label: 'Two' } }, + }]); + check('two source strings on one key are reported as ambiguous, not silently merged', + clash.ambiguous.length === 1, JSON.stringify(clash.ambiguous)); + + let refusedNonString = false; + try { + flatten({ a: [1, 2] }, ''); + } catch (error) { + refusedNonString = error instanceof CheckError; + } + check('a non-string leaf is refused rather than skipped', refusedNonString, + 'flatten() accepted an array leaf'); + + console.log(''); + console.log(bold('◆ i18n source parity — self-test')); + console.log('────────────────────────────────────────'); + if (failures.length === 0) { + console.log(` ${green('✓')} ${assertions} assertions — the comparator still reports drift, orphans and gaps`); + console.log(''); + return 0; + } + for (const line of failures) console.log(` ${red('✗')} ${line}`); + console.log(''); + console.log(` ${red(bold(`${failures.length} self-test failure(s)`))} — the gate cannot be trusted to bite; ` + + 'fix it before trusting a green run.'); + console.log(''); + return 1; +} + +// ─────────────────────────────────────────────────────────────────────────────── + +try { + process.exitCode = process.argv.includes('--self-test') ? selfTest() : run(); +} catch (error) { + const message = error instanceof CheckError ? error.message : (error?.stack ?? String(error)); + console.log(''); + console.log(` ${red(bold('x i18n source parity could not run'))}`); + for (const line of String(message).split('\n')) console.log(` ${line}`); + console.log(''); + process.exitCode = 1; +} diff --git a/src/translations/en.ts b/src/translations/en.ts index c66e1cf..6fc816e 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -9,17 +9,23 @@ import type { TranslationData } from '@objectstack/spec/system'; * `en` is the source locale: every inline `label:` in src/ already IS the * English text, and the runtime falls back to it. This file restates those * strings so the two locale files have the same shape and a translator can - * diff them line for line. ⚠️ That means a label renamed in an object file - * must be renamed here too — a stale entry here wins over the source label at - * runtime, and no gate catches it (coverage counts a key as translated as soon - * as it exists). + * diff them line for line. That means a label renamed in an object file must be + * renamed here too — a stale entry here wins over the source label at runtime. * - * Coverage is measured, not asserted: `pnpm lint` runs `objectstack lint - * --i18n-strict`, which fails on any key this file lacks for a locale in - * `supportedLocales` (objectstack.config.ts). `npx objectstack i18n check - * --show-keys` lists the gaps. The Studio's own metadata-form strings are not - * here on purpose — the platform packages ship those, and lint hides that - * bucket unless `--include-platform` is passed. + * That trap is now gated (#63). `pnpm lint` runs `pnpm check:i18n-source`, which + * compares all 507 keys below against the labels the metadata declares in + * `dist/objectstack.json` and fails on three kinds of drift: a value that no + * longer matches its source label, a key whose metadata is gone, and a source + * label this file never restates. Run it alone with `pnpm check:i18n-source`; + * `scripts/check-i18n-source.mjs` documents how each key is resolved. + * + * Coverage is a different question and is also measured: `objectstack lint + * --i18n-strict` fails on any key this file lacks for a NON-default locale in + * `supportedLocales` (objectstack.config.ts) — it cannot speak for `en`, which + * it counts as 100 % translated because the source label is the translation. + * `npx objectstack i18n check --show-keys` lists the gaps. The Studio's own + * metadata-form strings are not here on purpose — the platform packages ship + * those, and lint hides that bucket unless `--include-platform` is passed. * * Keys are the object / field / option / view / app / dashboard / dataset * NAMES, never their labels (`objects.ats_job.fields.status.options.published`). From 328c54bb3225972044d4b455c07b90f3af8dd25b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:35:22 +0000 Subject: [PATCH 2/2] test(ci): deliberate label drift so CI exercises the new gate DO NOT MERGE. Renames ats_job.fields.status from 'Status' to 'Review status' in the source while src/translations/en.ts still says 'Status', so the CI run on this branch executes pnpm check:i18n-source against a real mismatch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/objects/job.object.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objects/job.object.ts b/src/objects/job.object.ts index e3136ea..0e76273 100644 --- a/src/objects/job.object.ts +++ b/src/objects/job.object.ts @@ -129,7 +129,7 @@ export const Job = ObjectSchema.create({ // ── Lifecycle ─────────────────────────────────────────────────────── status: Field.select({ - label: 'Status', + label: 'Review status', required: true, defaultValue: 'draft', options: [