From d93ca30cb7a407b41392719d19df08f3bb266a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:34:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20en=20=E6=96=87=E6=A1=88?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E5=BF=85=E9=A1=BB=E7=94=B1=E4=B9=9D=E4=B8=AA?= =?UTF-8?q?=E8=AF=91=E6=96=87=E5=8C=85=E5=90=8C=E6=89=B9=E8=B7=9F=E6=94=B9?= =?UTF-8?q?=E7=9A=84=E9=97=A8=E7=A6=81=20(#3650)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 scripts/check-i18n-en-drift.mjs:以 merge-base 为基线比对十个语言包的 值,en 某 key 的值变了而其余九包该 key 的值没变即失败并逐 key 点名。这是 #3582/#3625 一族缺陷缺的那道不变量 —— 现有三道门禁全部只读键集合,对「值 的语义漂移」天生失明,而 #3625 证明连「值是英文」「非拉丁包里有 ASCII」这 类判据也看不见它(八包存的是地道译文,只是译了一句 en 已废弃的话)。 挂账文件 scripts/i18n-en-drift-baseline.json 落地为空,条目须逐字转写新的 en 文案 —— 因此只能由已经做出该改动的人写,只豁免那一句,且下次该 key 再 变时自动过期。数量方向由测试里的 WAIVER_CEILING 钉住。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .github/workflows/ci.yml | 21 + package.json | 1 + .../__tests__/all-locales-key-parity.test.ts | 18 + scripts/__tests__/check-i18n-en-drift.test.ts | 616 +++++++++++++++++ scripts/check-i18n-call-site-keys.mjs | 14 + scripts/check-i18n-en-drift.mjs | 629 ++++++++++++++++++ scripts/i18n-en-drift-baseline.json | 26 + 7 files changed, 1325 insertions(+) create mode 100644 scripts/__tests__/check-i18n-en-drift.test.ts create mode 100644 scripts/check-i18n-en-drift.mjs create mode 100644 scripts/i18n-en-drift-baseline.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0770ae1b7a..aa9f1e84f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,15 @@ jobs: uses: actions/checkout@v7 with: submodules: true + # `pnpm check:i18n-drift` (below) compares the locale packs on this + # branch against the packs at its MERGE BASE with the target branch, + # so it needs history — and checkout's default is a depth-1 clone, + # where `git merge-base` has nothing to find. The gate treats an + # unresolvable base as a hard failure rather than a skip, so getting + # this wrong is a red build rather than a silent pass; it is spelled + # out here so it stays that way. Pinned by + # `scripts/__tests__/check-i18n-en-drift.test.ts`. + fetch-depth: 0 - name: Enable Corepack run: corepack enable @@ -93,6 +102,18 @@ jobs: - name: Verify t() call-site keys exist in the en locale pack run: pnpm check:i18n-keys + # The step above and `all-locales-key-parity.test.ts` both read KEYS. When + # an `en` VALUE changes and the nine translations do not, every key-shaped + # gate stays green — objectui#3582 and objectui#3625 were eight packs each + # serving a retired sentence, the second one as idiomatic native-script + # translations that no mechanical judgement can distinguish from healthy. + # This gate judges the event instead of the state: `en` changed here, the + # translations must change here too. Needs the install (it parses the + # packs with `typescript`) and the `fetch-depth: 0` above (it diffs + # against the merge base), but nothing built. + - name: Verify changed en strings were followed by the nine translations + run: pnpm check:i18n-drift + # `scripts/` is not a workspace package, so `pnpm type-check` (i.e. # `turbo run type-check`, which walks package.json `scripts`) structurally # cannot reach it, and the coverage guard above decides coverage per diff --git a/package.json b/package.json index fa361f9060..68b274863f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs", "check:control-bytes": "node scripts/check-control-bytes.mjs", "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", + "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index dfecb6a64c..669b06783b 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -20,6 +20,24 @@ * "happen to" render. * * The only permitted exception is the outbound-message set below. + * + * ## What this test does NOT own + * + * Key sets and placeholder shape, and nothing about what a value SAYS. Two + * sibling gates split the rest, and the boundaries are load-bearing: + * + * - `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) — a key a `t()` + * call site asks for that NO pack defines. Ten packs identically missing it + * is full parity, so this file is green on it by construction. + * - `scripts/check-i18n-en-drift.mjs` (objectui#3650) — when an `en` VALUE + * changes, the nine translations must change in the same PR. This file was + * green through objectui#3582 and objectui#3625, correctly: neither touched + * a key set or a placeholder. Trying to make it red on those would be asking + * a key-set test to judge meaning. + * + * That gate skips any key a pack does not define — including the four + * `OUTBOUND_KEYS` below — precisely because their key sets are this file's + * business, so the two cannot contradict each other on the same fact. */ import { describe, it, expect } from 'vitest'; import { builtInLocales } from '../locales'; diff --git a/scripts/__tests__/check-i18n-en-drift.test.ts b/scripts/__tests__/check-i18n-en-drift.test.ts new file mode 100644 index 0000000000..334ef5fd56 --- /dev/null +++ b/scripts/__tests__/check-i18n-en-drift.test.ts @@ -0,0 +1,616 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + analyze, + applyLedger, + checkLedger, + findDrift, + FOLLOWERS, + ISSUE_PATTERN, + LEDGER_PATH, + LOCALES, + packPath, + parsePack, + readLedger, + readPacks, + resolveBaseRef, +} from '../check-i18n-en-drift.mjs'; + +/** + * objectui#3650 — the behaviour test for `scripts/check-i18n-en-drift.mjs`. + * + * The gate answers "did the nine translation packs follow the `en` string this + * PR changed?", which no other i18n guard in this repo can: parity compares KEY + * SETS, the call-site guard checks that a key EXISTS, and both were green + * through objectui#3582 and objectui#3625 — eight packs serving a retired + * sentence, the second time as idiomatic native-script translations that every + * value-shaped heuristic also reads as healthy. + * + * ## Why this suite is built out of implanted commits + * + * The gate cannot be validated against this repo's history: the ten packs + * arrived whole at 30ac2e1ee ALREADY carrying both drifts (`en` said "read-only" + * and `ja` said the duplicate-to-customize sentence at that very commit), and + * across all 21 commits that have touched `en.ts` since, exactly one changed an + * `en` value and all nine packs followed it. A replay is therefore green + * everywhere, and a green replay is not evidence. + * + * So the corpus below is planted. `reconstructed3625()` rebuilds the commit that + * must have existed — the real sentences, the real eight packs — inside a + * throwaway git repo, and the CLI tests drive the real script over real git + * plumbing to pin exit codes rather than internal shapes. When this suite is + * green, what has been measured is that the gate reddens on the defect it was + * written for and stays green on the shape that is fine. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const gateScript = path.join(repoRoot, 'scripts/check-i18n-en-drift.mjs'); +const tempRoots: string[] = []; + +/** + * The real `en` pack, read the way its consumers read it: evaluated by the + * module loader, not parsed. + * + * A COMPUTED dynamic import on purpose. A static specifier would pull a + * 3.2k-line package source into `tsconfig.scripts.json`'s program, and that + * project's position in `ci.yml` rests on the premise that it reads nothing + * outside `scripts/` — pinned by `scripts-type-check.test.ts`, whose regex looks + * for workspace-package specifiers and would not have caught a relative one. + * `check-i18n-call-site-keys.test.ts` reads the same pack the same way, for the + * same reason. + */ +const realEn: unknown = ( + await import(pathToFileURL(path.join(repoRoot, 'packages/i18n/src/locales/en.ts')).href) +).default; + +/** `dotted key -> value` for a plain object — the shape the gate parses out of source. */ +function leafValues(node: unknown, prefix = '', into = new Map()): Map { + if (node !== null && typeof node === 'object') { + for (const [key, value] of Object.entries(node as Record)) { + leafValues(value, prefix ? `${prefix}.${key}` : key, into); + } + } else { + into.set(prefix, node); + } + return into; +} + +// -- fixture packs ------------------------------------------------------------ + +type Nested = { [key: string]: string | Nested }; + +/** A pack module source, the way the real ones are written. */ +function packSource(lang: string, tree: Nested): string { + const render = (node: Nested, indent: string): string => + Object.entries(node) + .map(([key, value]) => + typeof value === 'string' + ? `${indent}${key}: ${JSON.stringify(value)},` + : `${indent}${key}: {\n${render(value, `${indent} `)}\n${indent}},`, + ) + .join('\n'); + return `const ${lang} = {\n${render(tree, ' ')}\n} as const;\n\nexport default ${lang};\n`; +} + +/** Ten packs from one `lang -> tree` map. Every locale must be present. */ +function packFiles(trees: Record): Record { + return Object.fromEntries(LOCALES.map((lang) => [packPath(lang), packSource(lang, trees[lang])])); +} + +/** `lang -> tree` for all ten locales, built per locale. */ +function treesFor(make: (lang: string) => Nested): Record { + return Object.fromEntries(LOCALES.map((lang): [string, Nested] => [lang, make(lang)])); +} + +/** + * Fixture packs hold ten keys, not thousands, so the gate's anti-empty-comparison + * floor has to be lowered for them. CI never passes this — the default (2000) is + * the number that guards the real packs, and one test below drives the CLI + * WITHOUT this flag precisely to prove the floor still fires. + */ +const FIXTURE_FLOOR = ['--min-keys', '1']; + +/** A throwaway git repo. `commits` are applied in order, each one committed. */ +function repoWithCommits(commits: Array>): { root: string; shas: string[] } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-i18n-en-drift-')); + tempRoots.push(root); + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'gate@example.test'); + git('config', 'user.name', 'gate'); + + const shas: string[] = []; + for (const files of commits) { + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + git('add', '-A', '-f'); + git('commit', '-q', '-m', `commit ${shas.length + 1}`); + shas.push(git('rev-parse', 'HEAD')); + } + return { root, shas }; +} + +/** Runs the real gate as CI runs it. Returns the exit code and the merged output. */ +function runGate(root: string, args: string[]): { code: number; output: string } { + const result = execFileSync( + process.execPath, + [gateScript, '--root', root, ...args], + // The two env vars the gate reads are cleared: on a real CI run they name + // THIS repo's branches, and a fixture repo must not be judged against them. + { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, OS_I18N_DRIFT_BASE: '', GITHUB_BASE_REF: '' }, + }, + // `execFileSync` throws on a non-zero exit; the status and both streams are + // on the error, so the two paths are unified here rather than at each site. + ); + return { code: 0, output: result }; +} + +function runGateExpectingFailure(root: string, args: string[]): { code: number; output: string } { + try { + runGate(root, args); + } catch (error) { + const failure = error as { status: number; stdout: string; stderr: string }; + return { code: failure.status, output: `${failure.stdout}${failure.stderr}` }; + } + throw new Error('the gate exited 0 where a failure was expected'); +} + +// -- the objectui#3625 commit, reconstructed ---------------------------------- + +/** + * The two sentences at the centre of objectui#3582 and objectui#3625, verbatim + * from those issues. `en` moves to the read-only wording; the eight packs keep + * their translations of the retired duplicate-to-customize wording. `zh` follows + * `en`, as it really did. + */ +const RETIRED: Record = { + en: 'System view — duplicate to customize.', + zh: '系统视图 — 复制一份来自定义。', + ja: 'システムビュー — 複製してカスタマイズしてください。', + ko: '시스템 보기 — 사용자 지정하려면 복제하세요.', + de: 'Systemansicht — zum Anpassen duplizieren.', + fr: 'Vue système — dupliquer pour personnaliser.', + es: 'Vista del sistema — duplique para personalizar.', + pt: 'Exibição do sistema — duplique para personalizar.', + ru: 'Системное представление — скопируйте для настройки.', + ar: 'عرض النظام — انسخ للتخصيص.', +}; +const EN_READONLY = 'System view — defined in code, read-only.'; +const ZH_READONLY = '系统视图 — 由代码定义,只读。'; + +function reconstructed3625(): { root: string; base: string; head: string } { + const before = packFiles( + treesFor((lang) => ({ view: { readonlyTooltip: RETIRED[lang] }, common: { save: `save-${lang}` } })), + ); + const after = { + ...before, + [packPath('en')]: packSource('en', { + view: { readonlyTooltip: EN_READONLY }, + common: { save: 'save-en' }, + }), + [packPath('zh')]: packSource('zh', { + view: { readonlyTooltip: ZH_READONLY }, + common: { save: 'save-zh' }, + }), + }; + const { root, shas } = repoWithCommits([before, after]); + return { root, base: shas[0], head: shas[1] }; +} + +afterAll(() => { + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +// -- the parser --------------------------------------------------------------- + +describe('the parsed en pack equals the module vitest actually evaluates', () => { + const parsed = parsePack(fs.readFileSync(path.join(repoRoot, packPath('en')), 'utf8'), 'en.ts'); + const runtime = leafValues(realEn); + + it('extracts exactly the real pack, key for key AND value for value', () => { + // Values, not just keys: a parser that got the text wrong would compare two + // wrong strings and report drift that is not there — or, worse, miss drift + // that is. `check-i18n-call-site-keys.test.ts` pins the key half of the same + // extraction; this is the half that only matters here. + const missed = [...runtime.keys()].filter((key) => !parsed.has(key)).sort(); + const invented = [...parsed.keys()].filter((key) => !runtime.has(key)).sort(); + const wrong = [...runtime.entries()] + .filter(([key, value]) => parsed.has(key) && parsed.get(key) !== value) + .map(([key]) => key) + .sort(); + expect({ missed, invented, wrong }).toEqual({ missed: [], invented: [], wrong: [] }); + }); + + it('is comparing a whole pack, not an empty one', () => { + expect(parsed.size).toBeGreaterThan(2000); + }); + + it('folds a sentence written as a concatenation across source lines', () => { + // `objectActions.resetPackageSetConfirm` is spelled `'…' + '…'` in en.ts. + // i18next serves the folded string, so the folded string is what must be + // compared — and a parser that treated the node as opaque would silently + // drop the key out of this gate's sight. + const key = 'objectActions.resetPackageSetConfirm'; + expect(parsed.get(key)).toBe(runtime.get(key)); + expect(parsed.get(key)).toContain('cannot be removed. Deleting resets it'); + }); + + it('refuses a value form it cannot resolve instead of skipping the key', () => { + expect(() => + parsePack('const en = { a: { b: someRuntimeValue } } as const;\nexport default en;\n', 'fixture'), + ).toThrow(/neither a nested object nor a static string/); + }); + + it('finds the pack object through `export default`, not through the file name', () => { + // Historical revisions are parsed too, where "the binding is named after the + // locale" is an assumption rather than a fact. + const parsedOdd = parsePack('const table = { a: "x" } as const;\nexport default table;\n', 'fixture'); + expect([...parsedOdd]).toEqual([['a', 'x']]); + }); +}); + +// -- what counts as drift ----------------------------------------------------- + +describe('findDrift — an en value that changed without its translations', () => { + const packsOf = (trees: Record) => + new Map(LOCALES.map((lang) => [lang, parsePack(packSource(lang, trees[lang]), lang)])); + const uniform = (value: (lang: string) => string): Record => + treesFor((lang) => ({ greeting: value(lang) })); + + it('names every pack that did not follow', () => { + const before = packsOf(uniform((lang) => `old-${lang}`)); + const after = packsOf({ ...uniform((lang) => `old-${lang}`), en: { greeting: 'new-en' } }); + const { findings, counters } = findDrift(before, after); + expect(findings).toEqual([ + { key: 'greeting', enBefore: 'old-en', enAfter: 'new-en', unfollowed: FOLLOWERS }, + ]); + expect(counters.changedEnKeys).toBe(1); + expect(counters.followedPacks).toBe(0); + }); + + it('is silent when the same key changed in all ten packs', () => { + const before = packsOf(uniform((lang) => `old-${lang}`)); + const after = packsOf(uniform((lang) => `new-${lang}`)); + const { findings, counters } = findDrift(before, after); + expect(findings).toEqual([]); + expect(counters.followedPacks).toBe(9); + }); + + it('reports only the packs that stood still, not the ones that moved', () => { + const before = packsOf(uniform((lang) => `old-${lang}`)); + const after = packsOf({ + ...uniform((lang) => `old-${lang}`), + en: { greeting: 'new-en' }, + ja: { greeting: 'new-ja' }, + ar: { greeting: 'new-ar' }, + }); + const { findings } = findDrift(before, after); + expect(findings[0].unfollowed).toEqual(['zh', 'ko', 'de', 'fr', 'es', 'pt', 'ru']); + }); + + it('leaves added and removed en keys to all-locales-key-parity', () => { + // A key set change is that test's finding, in both directions. Reporting it + // here too would make one defect fail two gates with two different stories. + const before = packsOf(uniform((lang) => `old-${lang}`)); + const after = new Map(before); + after.set('en', parsePack(packSource('en', { greeting: 'old-en', added: 'brand new' }), 'en')); + const grown = findDrift(before, after); + expect(grown.findings).toEqual([]); + expect(grown.counters.addedEnKeys).toBe(1); + + const shrunk = findDrift(after, before); + expect(shrunk.findings).toEqual([]); + expect(shrunk.counters.removedEnKeys).toBe(1); + }); + + it('skips — and counts — a key a pack does not define', () => { + // The four `console.ai.*` outbound-message keys live in en and zh only, ON + // PURPOSE (see all-locales-key-parity.test.ts / outbound-agent-messages.test.ts). + // Demanding the other eight "follow" a key they must not define would put + // this gate in direct contradiction with the one that owns key sets. + const trees = treesFor((lang): Nested => + lang === 'en' || lang === 'zh' ? { outbound: `old-${lang}` } : { unrelated: `x-${lang}` }, + ); + const before = packsOf(trees); + const after = packsOf({ ...trees, en: { outbound: 'new-en' }, zh: { outbound: 'new-zh' } }); + const { findings, counters } = findDrift(before, after); + expect(findings).toEqual([]); + expect(counters.absentInPack).toBe(8); + }); + + it('does not exempt a typography-only edit', () => { + // Deliberate, and the waiver ledger is the exemption channel instead. A + // punctuation heuristic would be a claim about MEANING dressed as a regex, + // and objectui#3625 is what those claims cost. + const before = packsOf(uniform(() => 'Save')); + const after = packsOf({ ...uniform(() => 'Save'), en: { greeting: 'Save.' } }); + expect(findDrift(before, after).findings.map((f) => f.key)).toEqual(['greeting']); + }); +}); + +// -- the defect this gate exists for, replanted -------------------------------- + +describe('the objectui#3625 commit, reconstructed in a real git repo', () => { + it('names the key and all eight packs that kept the retired sentence', () => { + const { root, base, head } = reconstructed3625(); + const { findings } = analyze(root, { base, head }); + expect(findings).toHaveLength(1); + expect(findings[0].key).toBe('view.readonlyTooltip'); + expect(findings[0].enBefore).toBe(RETIRED.en); + expect(findings[0].enAfter).toBe(EN_READONLY); + // zh followed and is absent; the eight that did not are all named. + expect(findings[0].unfollowed).toEqual(['ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']); + }); + + it('fails the build, and the message says which packs and what the sentence became', () => { + const { root, base, head } = reconstructed3625(); + const { code, output } = runGateExpectingFailure(root, [...FIXTURE_FLOOR, '--base', base, '--head', head]); + expect(code).toBe(1); + expect(output).toContain('view.readonlyTooltip'); + expect(output).toContain(EN_READONLY); + expect(output).toContain('unchanged in: ja, ko, de, fr, es, pt, ru, ar'); + }); + + it('refuses to judge packs too small to be the real ones', () => { + // Same reconstruction, run the way CI runs it — no lowered floor. The fixture + // packs hold two keys, so the gate reports a collapsed scan instead of a + // verdict. This is the anti-empty-comparison guard that every other gate in + // this family opens with, and driving the CLI once without `--min-keys` is + // what keeps it from being a line nothing exercises. + const { root, base, head } = reconstructed3625(); + const { code, output } = runGateExpectingFailure(root, ['--base', base, '--head', head]); + expect(code).toBe(1); + expect(output).toContain('The scan collapsed'); + expect(output).toContain('expected at least 2000'); + }); + + it('goes green when the same commit translates all nine packs', () => { + const before = packFiles(treesFor((lang) => ({ view: { readonlyTooltip: RETIRED[lang] } }))); + const after = packFiles( + treesFor((lang) => ({ view: { readonlyTooltip: `${RETIRED[lang]} [read-only]` } })), + ); + const { root, shas } = repoWithCommits([before, after]); + const { code, output } = runGate(root, [...FIXTURE_FLOOR, '--base', shas[0], '--head', shas[1]]); + expect(code).toBe(0); + expect(output).toContain('Every changed en value was followed by all nine translation packs.'); + }); +}); + +// -- the waiver ledger -------------------------------------------------------- + +/** The reconstructed drift plus a ledger, as a two-commit repo the CLI can read. */ +function reconstructed3625WithLedger(ledger: unknown): { root: string; base: string; head: string } { + const { root, base, head } = reconstructed3625(); + fs.mkdirSync(path.join(root, 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(root, LEDGER_PATH), `${JSON.stringify(ledger, null, 2)}\n`); + return { root, base, head }; +} + +const validWaiver = { + waivers: { + 'view.readonlyTooltip': { + en: EN_READONLY, + reason: 'fixture waiver', + issue: 'objectui#3650', + }, + }, +}; + +describe('the waiver ledger', () => { + it('waives the finding when it transcribes the new en text verbatim', () => { + const { root, base, head } = reconstructed3625WithLedger(validWaiver); + const { code, output } = runGate(root, [...FIXTURE_FLOOR, '--base', base, '--head', head]); + expect(code).toBe(0); + expect(output).toContain('1 finding(s) waived by the ledger'); + }); + + it('waives nothing when the transcription is off by so much as a word', () => { + // The transcription is the mechanism, not paperwork: it is what stops a + // waiver from covering the NEXT edit to the same key. + const { root, base, head } = reconstructed3625WithLedger({ + waivers: { + 'view.readonlyTooltip': { en: 'System view — read only.', reason: 'fixture', issue: 'objectui#3650' }, + }, + }); + const { code, output } = runGateExpectingFailure(root, [...FIXTURE_FLOOR, '--base', base, '--head', head]); + expect(code).toBe(1); + expect(output).toContain('stale-waiver'); + expect(output).toContain('view.readonlyTooltip'); + }); + + it('expires by itself the next time the key changes', () => { + // The property that keeps the ledger from turning into an allowlist: a third + // commit edits `en` again, and last PR's waiver stops applying — for the + // fresh drift AND as a stale entry of its own. + const { root, head } = reconstructed3625WithLedger(validWaiver); + const third = { + [packPath('en')]: packSource('en', { + view: { readonlyTooltip: 'System view — defined in code. Read-only.' }, + common: { save: 'save-en' }, + }), + }; + for (const [rel, contents] of Object.entries(third)) fs.writeFileSync(path.join(root, rel), contents); + execFileSync('git', ['add', '-A', '-f'], { cwd: root }); + execFileSync('git', ['commit', '-q', '-m', 'en changes again'], { cwd: root }); + const newHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + + const { code, output } = runGateExpectingFailure(root, [...FIXTURE_FLOOR, '--base', head, '--head', newHead]); + expect(code).toBe(1); + expect(output).toContain('stale-waiver'); + expect(output).toContain('the en text this waiver covers is gone'); + }); + + it('rejects an entry for a key en does not have', () => { + const enAfter = new Map([['real.key', 'text']]); + const problems = checkLedger( + { waivers: { 'ghost.key': { en: 'text', reason: 'r', issue: 'objectui#1' } } }, + enAfter, + ); + expect(problems.map((p) => p.reason)).toEqual(['stale-waiver']); + expect(problems[0].detail).toContain('no longer exists in the en pack'); + }); + + it('rejects an entry that does not say what it covers or why', () => { + const enAfter = new Map([['real.key', 'text']]); + const problems = checkLedger( + { waivers: { 'real.key': { en: 'text', reason: ' ', issue: '3650' } } }, + enAfter, + ); + expect(problems.map((p) => p.detail).sort()).toEqual([ + 'missing a `reason`', + 'missing an `issue` of the form objectui#1234', + ]); + }); + + it('does not treat a waiver that fired nothing as an error', () => { + // Every run after the waiving PR merges sees an empty diff and this exact + // shape. Failing it would hand one PR's escape hatch to every later PR as a + // red build; quantity is ratcheted by the ceiling below instead. + const enAfter = new Map([['real.key', 'text']]); + expect(checkLedger({ waivers: { 'real.key': { en: 'text', reason: 'r', issue: 'objectui#1' } } }, enAfter)).toEqual( + [], + ); + }); + + it('never lets a stale waiver suppress a finding', () => { + const finding = { key: 'k', enBefore: 'a', enAfter: 'b', unfollowed: ['ja'] }; + const enAfter = new Map([['k', 'b']]); + expect(applyLedger([finding], { waivers: { k: { en: 'b', reason: 'r', issue: 'objectui#1' } } }, enAfter).waived) + .toHaveLength(1); + // Same entry, but `en` has moved on: it waives nothing. + expect( + applyLedger([finding], { waivers: { k: { en: 'a', reason: 'r', issue: 'objectui#1' } } }, enAfter).unwaived, + ).toHaveLength(1); + }); +}); + +/** + * The ratchet. The ledger ships empty and this ceiling is the only thing that + * makes growing it a visible act: adding an entry means raising the number here, + * in the same PR, where a reviewer reads it next to the transcribed sentence. + * Lowering it is free and should happen whenever an entry is deleted. + * + * Deliberately a count, not `toEqual({})`: a ledger that may legitimately hold + * one entry should still be pinned at one, and `toEqual({})` cannot express that. + */ +const WAIVER_CEILING = 0; + +describe('the shipped ledger', () => { + const ledger = readLedger(repoRoot); + + it(`holds at most ${WAIVER_CEILING} waiver(s) — a ratchet, and this number only goes down`, () => { + expect(Object.keys(ledger.waivers)).toHaveLength(WAIVER_CEILING); + }); + + it('has no entry that the current en pack contradicts', () => { + const enAfter = parsePack(fs.readFileSync(path.join(repoRoot, packPath('en')), 'utf8'), 'en.ts'); + expect(checkLedger(ledger, enAfter)).toEqual([]); + for (const entry of Object.values(ledger.waivers) as Array<{ issue: string }>) { + expect(entry.issue).toMatch(ISSUE_PATTERN); + } + }); + + it('is real JSON with the shape the gate reads', () => { + const raw = JSON.parse(fs.readFileSync(path.join(repoRoot, LEDGER_PATH), 'utf8')); + expect(raw.waivers).toBeTypeOf('object'); + expect(Array.isArray(raw.note)).toBe(true); + }); +}); + +// -- the base commit ---------------------------------------------------------- + +describe('resolving the commit to compare against', () => { + it('fails loudly rather than passing when there is no base to diff', () => { + // The failure mode this whole family of gates exists to prevent: a diff gate + // that finds no diff and reports green. A shallow CI checkout (the default) + // produces exactly that if the gate is permissive about it. + const { root } = repoWithCommits([packFiles(treesFor((lang) => ({ a: `x-${lang}` })))]); + execFileSync('git', ['checkout', '-q', '-b', 'detached-from-main'], { cwd: root }); + execFileSync('git', ['branch', '-q', '-D', 'main'], { cwd: root }); + + const resolved = resolveBaseRef(root, { env: {} }); + expect(resolved.ok).toBe(false); + + const { code, output } = runGateExpectingFailure(root, []); + expect(code).toBe(1); + expect(output).toContain('Cannot resolve the commit to compare against'); + expect(output).toContain('a diff gate with no diff would pass while'); + }); + + it('prefers the merge base with the PR base branch when CI names one', () => { + const { root, shas } = repoWithCommits([ + packFiles(treesFor((lang) => ({ a: `x-${lang}` }))), + packFiles(treesFor((lang) => ({ a: `y-${lang}` }))), + ]); + execFileSync('git', ['remote', 'add', 'origin', root], { cwd: root }); + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', shas[0]], { cwd: root }); + + expect(resolveBaseRef(root, { env: { GITHUB_BASE_REF: 'main' } })).toMatchObject({ + ok: true, + ref: shas[0], + how: 'merge-base with origin/main', + }); + }); + + it('reads the working tree as the after side when no head is named', () => { + // So an agent editing en.ts locally gets the answer before committing. + const { root, shas } = repoWithCommits([packFiles(treesFor((lang) => ({ a: `x-${lang}` })))]); + fs.writeFileSync(path.join(root, packPath('en')), packSource('en', { a: 'edited in the working tree' })); + const after = readPacks(root, null); + expect(after.get('en')!.get('a')).toBe('edited in the working tree'); + const { findings } = analyze(root, { base: shas[0] }); + expect(findings.map((f) => f.key)).toEqual(['a']); + }); +}); + +// -- wiring ------------------------------------------------------------------- + +describe('the gate is wired to run', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + + it('package.json exposes it as a named script', () => { + expect(pkg.scripts['check:i18n-drift']).toBe('node scripts/check-i18n-en-drift.mjs'); + }); + + it('ci.yml runs it after the install it needs (it imports typescript)', () => { + const install = ci.indexOf('pnpm install --frozen-lockfile'); + const step = ci.indexOf('run: pnpm check:i18n-drift'); + expect(step, 'ci.yml does not run `pnpm check:i18n-drift`').toBeGreaterThan(-1); + expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); + }); + + it('gives that job a full-history checkout, or the merge base does not exist', () => { + // actions/checkout defaults to depth 1. Without this the gate cannot resolve + // a base — and by design that is a red build, not a silent pass, so losing + // this line breaks CI loudly. The pin is here so the reason survives. + const job = ci.slice(ci.indexOf(' type-check:'), ci.indexOf('run: pnpm check:i18n-drift')); + expect(job).toContain('fetch-depth: 0'); + }); + + it('is not hidden from the locale packs by ci.yml path filters', () => { + // ci.yml `paths-ignore`s markdown, content/, docs/, apps/site/ and + // .changeset/. None of them can match `packages/i18n/src/locales/*.ts` or the + // ledger, so a PR that edits an en string always starts this workflow. A new + // entry that DID cover them would make the gate unreachable for exactly the + // PRs it judges — the objectui#3547 / control-bytes.yml lesson, one workflow + // over. + const ignored = ci.slice(0, ci.indexOf('jobs:')).match(/^\s+- '.*'$/gm) ?? []; + for (const pattern of ignored) { + expect(pattern).not.toMatch(/packages|scripts|locales/); + } + expect(ignored.length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs index fa3eecb132..bcfdeff5b8 100644 --- a/scripts/check-i18n-call-site-keys.mjs +++ b/scripts/check-i18n-call-site-keys.mjs @@ -23,6 +23,20 @@ * the class, not the instance: the first full run over `main` found * 258 more. * + * ## Division of labour with the other two i18n gates + * + * All three read the same ten packs, and each is blind to what the next owns: + * + * - THIS gate: call site -> `en`. Does the key a component asks for exist? + * - `packages/i18n/src/__tests__/all-locales-key-parity.test.ts`: pack vs pack + * KEY SETS, plus placeholder shape. + * - `scripts/check-i18n-en-drift.mjs` (objectui#3650): the only one that reads + * VALUES, and only as an event — when an `en` string CHANGES, the nine + * translations must change in the same PR (or be waived). Neither key gate + * can see a value go stale: objectui#3582 and objectui#3625 were eight packs + * serving a retired sentence at full key parity, the second one in idiomatic + * native script that every value-shaped heuristic also reads as healthy. + * * ## What is IN scope, and why the answer is not "every `t(`" * * A naive grep for `t('...')` scores 3485 call sites in this repo and would be diff --git a/scripts/check-i18n-en-drift.mjs b/scripts/check-i18n-en-drift.mjs new file mode 100644 index 0000000000..e691895394 --- /dev/null +++ b/scripts/check-i18n-en-drift.mjs @@ -0,0 +1,629 @@ +#!/usr/bin/env node +/** + * When an `en` string CHANGES, the other nine packs must change with it. + * + * Run: node scripts/check-i18n-en-drift.mjs (also `pnpm check:i18n-drift`) + * Exit: 0 = every changed `en` value was followed (or waived), 1 = it was not + * + * ## The gap this closes (objectui#3650) + * + * Three i18n gates already run on every PR and all three read only KEYS: + * + * - `packages/i18n/src/__tests__/all-locales-key-parity.test.ts` — every pack + * defines every `en` key and no key `en` lacks. A **key-set** invariant. + * - `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) — every key a + * `t()` call site asks for exists in `en`. Also a **key** invariant. + * - that gate's baseline ratchet — same, one level down. + * + * None of them can see a VALUE go stale, and two shipped defects proved it: + * + * - objectui#3582: `console.objectView.systemViewReadonly` — `en` was changed + * from "duplicate to customize" to "read-only"; eight packs kept serving the + * old English sentence. + * - objectui#3625: `view.readonlyTooltip` — same `en` edit, same eight packs, + * except here the packs held **idiomatic native-script translations of the + * retired sentence**. Native script, mutually distinct, full key parity, + * correct placeholders: every mechanical judgement about the value itself + * was green, and stayed green. objectui#3582's suggested "no ASCII English + * in a non-Latin pack" probe would not have seen it either — measured, + * in PR #3642. + * + * The second case is the one that fixes the design. No gate can decide whether + * a Japanese sentence still MEANS what the English one now means; asking for + * that is asking for a translator. What a gate can decide is the **event**: the + * `en` value for this key changed in this PR and the nine translations did not. + * That is the invariant both defects violated at birth, and it is checkable + * exactly once — in the PR that edits `en`, while the author still knows what + * the sentence is supposed to say. A day later it is archaeology. + * + * ## What is compared, and against what + * + * The pack sources at the **merge base** of `HEAD` and `origin/main` versus the + * pack sources at `HEAD` (or the working tree). Merge base, not `origin/main`'s + * tip: a branch must answer for the `en` edits IT made, never for edits that + * landed on `main` after it forked. + * + * A key is "drifted" when it exists in `en` on BOTH sides with DIFFERENT values. + * For each drifted key, each of the nine packs must also show a different value + * across the same two commits. Granularity is the key — not the sentence, not + * the namespace, not the file. Two facts follow, and both are deliberate: + * + * - **A key ADDED to or REMOVED from `en` is not this gate's business.** That + * is a key-set change and `all-locales-key-parity.test.ts` already fails on + * it, in both directions. Reporting it here would double-report one defect. + * - **A pack that does not define the key is skipped for that key**, and the + * skips are counted and printed. The four `console.ai.*` outbound-message + * keys are absent from eight packs BY DESIGN (see + * `all-locales-key-parity.test.ts` and `outbound-agent-messages.test.ts`); + * demanding those packs "follow" a key they must not define would be this + * gate contradicting the one that owns key sets. + * + * ## Typography is NOT exempt + * + * Changing `en` from `Save` to `Save.` fails exactly like a rewrite. Two + * reasons, in this order: + * + * 1. There is no honest mechanical test for "this edit did not change the + * meaning". A punctuation-only heuristic is a semantic claim wearing a + * regex, and objectui#3625 is the case study in what those cost: every + * cheap judgement about that value was green while the sentence was wrong. + * 2. The waiver ledger below already IS the exemption channel, and it makes + * the claim a person signs rather than a rule the script guesses. A + * typography-only edit takes one ledger entry naming the new text. + * + * Measured before choosing, not assumed: replaying this gate over all 21 commits + * that have touched `en.ts` since the packs entered this repo turns up exactly + * ONE commit that changes an `en` value at all (0e50440e8, three keys), and all + * 27 of its pack/key pairs followed — green, correctly. So the observable cost + * of refusing an exemption is, on this history, zero commits: there is no + * typography-only churn here for a carve-out to spare. See the PR for + * objectui#3650 for the full replay. + * + * ## Why it lands green, and why that is not an empty green + * + * Neither defect's originating commit is reachable from here. The ten packs + * entered this repository whole at 30ac2e1ee, and at that very commit `en` + * already said "read-only" while `ja` already said the duplicate-to-customize + * sentence — both drifts arrived pre-broken, from wherever the files were moved + * in. The replay over everything since is green because in that window exactly + * one commit changed an `en` value and it did the right thing. + * + * So this gate cannot be validated by history, and the honest consequence is + * that its acceptance corpus is IMPLANTED rather than observed: + * `check-i18n-en-drift.test.ts` rebuilds the objectui#3625 commit that must have + * existed — `en` moved to the read-only sentence, the eight packs left holding + * their translations of the retired one — in a throwaway git repo, and asserts + * this gate names both keys and all eight packs. That, plus the exit-code tests + * around it, is the evidence that the green above is a measurement and not an + * empty comparison. + * + * ## The waiver ledger + * + * `scripts/i18n-en-drift-baseline.json`. It ships EMPTY, and unlike + * objectui#3547's baseline it can never hold a backlog, because this gate has + * no backlog to hold: it judges an event in a diff, not a state in the tree. + * There is no "the packs are currently 258 keys behind" to write down here — + * whether today's `ja` sentence still matches today's `en` sentence is the + * semantic question no gate can answer, and pretending otherwise is how + * objectui#3625 stayed green. + * + * An entry is `{ "": { "en": "", "reason": …, "issue": … } }` + * and it is checked against the tree on EVERY run, diff or no diff: + * + * - the key must still exist in `en`; + * - `entry.en` must still equal `en`'s CURRENT value for that key. + * + * That transcription is the whole mechanism. A waiver can only be written by + * someone who already made the edit (they have to copy the new sentence into + * the ledger, where a reviewer reads it next to the reason), it waives that one + * sentence and no other, and the NEXT time the key's `en` value changes the + * entry no longer matches and the build fails until someone deletes or renews + * it. Waivers expire by themselves; they do not accumulate into an allowlist. + * + * A waiver that fires no finding on a given run is NOT an error — after the + * waiving PR merges, every later run sees an empty diff, and failing them all + * would turn one PR's escape hatch into everyone else's red build. The ratchet + * on quantity is a pin in `scripts/__tests__/check-i18n-en-drift.test.ts`: the + * ledger's entry count has a ceiling in that test, so growing the ledger means + * editing a test that says so, in the PR that grows it. + * + * ## Why the packs are parsed from source rather than imported + * + * The "before" side is a blob inside git, not a file on disk, and the ten packs + * are 1.5 MB of TypeScript. `git show :` hands us the text and the + * TypeScript AST turns it into `key -> string` with no build step, no loader, + * and no temp files — the same trick, for the same reason, as `collectEnKeys` + * in `scripts/check-i18n-call-site-keys.mjs`. `check-i18n-en-drift.test.ts` + * pins the extraction against the real module vitest evaluates, so the parser + * cannot drift from the packs it claims to read. Anything the parser does not + * understand THROWS rather than being skipped: a silently dropped subtree would + * make this gate green over exactly the keys it stopped reading. + */ + +import ts from 'typescript'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** The ten built-in packs, `en` first. Mirrors `packages/i18n/src/locales/index.ts`. */ +export const LOCALES = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']; + +/** The nine packs that must follow `en`. */ +export const FOLLOWERS = LOCALES.filter((lang) => lang !== 'en'); + +/** Repo-relative path of a pack source. */ +export const packPath = (lang) => `packages/i18n/src/locales/${lang}.ts`; + +/** Where the waiver ledger lives, repo-relative. */ +export const LEDGER_PATH = 'scripts/i18n-en-drift-baseline.json'; + +/** A ledger entry must name the issue that justifies it, in this spelling. */ +export const ISSUE_PATTERN = /^objectui#\d+$/; + +// -- pack parsing ------------------------------------------------------------- + +/** Strips `as const`, `satisfies X` and parentheses off an expression node. */ +function unwrap(node) { + let current = node; + while ( + ts.isAsExpression(current) || + ts.isParenthesizedExpression(current) || + (ts.isSatisfiesExpression?.(current) ?? false) + ) { + current = current.expression; + } + return current; +} + +/** + * The object literal a pack module default-exports. + * + * Resolved through `export default ` rather than by assuming the binding + * is named after the file: this parser also reads HISTORICAL versions of these + * files, where that convention is an assumption rather than a fact. + */ +function packLiteral(source, label) { + let exported = null; + for (const statement of source.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + exported = unwrap(statement.expression); + } + } + if (exported === null) throw new Error(`${label}: no \`export default\` found`); + if (ts.isObjectLiteralExpression(exported)) return exported; + if (!ts.isIdentifier(exported)) { + throw new Error(`${label}: \`export default\` is neither an object literal nor an identifier`); + } + + const name = exported.text; + let literal = null; + const find = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === name && + node.initializer + ) { + const init = unwrap(node.initializer); + if (ts.isObjectLiteralExpression(init)) literal = init; + } + ts.forEachChild(node, find); + }; + find(source); + if (!literal) throw new Error(`${label}: cannot find the \`const ${name} = { … }\` object literal`); + return literal; +} + +/** + * The string a value node denotes, or `null` if it does not denote one. + * + * Handles the two literal forms plus `'a' + 'b'` concatenation chains, which is + * how a long sentence is wrapped across source lines: `en`'s + * `objectActions.resetPackageSetConfirm` is written that way today, and the + * folded text is what i18next serves, so it is what must be compared. (The + * value extractor's counterpart in `check-i18n-call-site-keys.mjs` treats such + * a node as an opaque leaf and is right to — it only needs the KEY. This gate + * needs the text, so it has to fold.) + */ +function stringValueOf(node) { + const value = unwrap(node); + if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) return value.text; + if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = stringValueOf(value.left); + const right = stringValueOf(value.right); + return left === null || right === null ? null : left + right; + } + return null; +} + +/** + * `dotted key -> string value` for one pack source. + * + * Throws on any property form it does not understand. Every leaf in all ten + * packs resolves to a string today (measured: 2736 keys in `en`, 2732 in the + * eight non-gate packs, all strings, one of them a two-part concatenation); a + * leaf that stops resolving must be a decision, not a silent omission that + * takes the key out of this gate's sight. + * + * @returns {Map} + */ +export function parsePack(text, label) { + const source = ts.createSourceFile(label, text, ts.ScriptTarget.Latest, true); + const values = new Map(); + + const walk = (object, prefix) => { + for (const prop of object.properties) { + if (!ts.isPropertyAssignment(prop)) { + throw new Error( + `${label}: unsupported property form at ${prefix || ''}: ${ts.SyntaxKind[prop.kind]}`, + ); + } + const name = + ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) || ts.isNumericLiteral(prop.name) + ? prop.name.text + : null; + if (name === null) throw new Error(`${label}: unsupported key form at ${prefix || ''}`); + + const path = prefix ? `${prefix}.${name}` : name; + const value = unwrap(prop.initializer); + if (ts.isObjectLiteralExpression(value)) { + walk(value, path); + continue; + } + const text = stringValueOf(value); + if (text === null) { + throw new Error( + `${label}: ${path} is neither a nested object nor a static string ` + + `(${ts.SyntaxKind[value.kind]}). This gate compares string values; teach it this form ` + + 'or the key silently leaves its scope.', + ); + } + values.set(path, text); + } + }; + walk(packLiteral(source, label), ''); + return values; +} + +// -- git ---------------------------------------------------------------------- + +function git(root, args) { + // stderr is PIPED, not inherited: the base-resolution probes below try refs + // that legitimately do not exist, and git's "Not a valid object name" for a + // probe that was supposed to miss reads as a real error in a CI log. + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function gitQuiet(root, args) { + try { + return git(root, args).trim(); + } catch { + return null; + } +} + +/** + * The ten packs at `ref`, or from the working tree when `ref` is null. + * + * The working tree is the default "after" side on purpose: an agent editing + * `en.ts` gets the answer before committing, not after pushing. + * + * @returns {Map>} lang -> (key -> value) + */ +export function readPacks(root, ref) { + const packs = new Map(); + for (const lang of LOCALES) { + const rel = packPath(lang); + const label = ref === null ? rel : `${ref}:${rel}`; + let text; + if (ref === null) { + const full = join(root, rel); + if (!existsSync(full)) throw new Error(`${rel} does not exist in the working tree`); + text = readFileSync(full, 'utf8'); + } else { + text = gitQuiet(root, ['show', `${ref}:${rel}`]); + if (text === null) throw new Error(`cannot read ${label} — is ${ref} in this clone?`); + } + packs.set(lang, parsePack(text, label)); + } + return packs; +} + +/** + * Which commit this branch should be judged against. + * + * Order: an explicit `--base`, then `OS_I18N_DRIFT_BASE`, then the merge base + * with the PR's own base branch (`GITHUB_BASE_REF`), then `origin/main`, then a + * local `main`. A base that cannot be resolved is a HARD FAILURE, never a skip: + * a diff gate that quietly finds nothing to diff reports green while checking + * nothing, which is the failure this whole family of gates exists to stop. + * + * @returns {{ ok: true, ref: string, how: string } | { ok: false, tried: string[], shallow: boolean }} + */ +export function resolveBaseRef(root, { explicit = null, env = process.env } = {}) { + const tried = []; + const attempt = (how, compute) => { + const value = compute(); + tried.push(`${how}${value ? '' : ' (unresolved)'}`); + return value ? { ok: true, ref: value, how } : null; + }; + + const candidates = [ + explicit ? () => attempt(`--base ${explicit}`, () => gitQuiet(root, ['rev-parse', '--verify', `${explicit}^{commit}`])) : null, + env.OS_I18N_DRIFT_BASE + ? () => attempt(`OS_I18N_DRIFT_BASE=${env.OS_I18N_DRIFT_BASE}`, () => gitQuiet(root, ['rev-parse', '--verify', `${env.OS_I18N_DRIFT_BASE}^{commit}`])) + : null, + env.GITHUB_BASE_REF + ? () => attempt(`merge-base with origin/${env.GITHUB_BASE_REF}`, () => gitQuiet(root, ['merge-base', 'HEAD', `origin/${env.GITHUB_BASE_REF}`])) + : null, + () => attempt('merge-base with origin/main', () => gitQuiet(root, ['merge-base', 'HEAD', 'origin/main'])), + () => attempt('merge-base with main', () => gitQuiet(root, ['merge-base', 'HEAD', 'main'])), + ].filter(Boolean); + + for (const candidate of candidates) { + const hit = candidate(); + if (hit) return hit; + } + return { ok: false, tried, shallow: gitQuiet(root, ['rev-parse', '--is-shallow-repository']) === 'true' }; +} + +// -- the comparison ----------------------------------------------------------- + +/** + * @typedef {{ key: string, enBefore: string, enAfter: string, unfollowed: string[] }} Finding + * @typedef {{ reason: string, key: string, detail: string }} LedgerProblem + */ + +/** + * Findings for every `en` value that changed without its translations. + * + * @param {Map>} before + * @param {Map>} after + * @returns {{ findings: Finding[], counters: Record }} + */ +export function findDrift(before, after) { + const enBefore = before.get('en'); + const enAfter = after.get('en'); + /** @type {Finding[]} */ + const findings = []; + const counters = { changedEnKeys: 0, addedEnKeys: 0, removedEnKeys: 0, followedPacks: 0, absentInPack: 0 }; + + for (const [key, value] of enAfter) { + if (!enBefore.has(key)) { + counters.addedEnKeys += 1; + continue; + } + const previous = enBefore.get(key); + if (previous === value) continue; + counters.changedEnKeys += 1; + + /** @type {string[]} */ + const unfollowed = []; + for (const lang of FOLLOWERS) { + const packBefore = before.get(lang); + const packAfter = after.get(lang); + // Absent on either side: a key-set fact, owned by all-locales-key-parity. + if (!packBefore.has(key) || !packAfter.has(key)) { + counters.absentInPack += 1; + continue; + } + if (packBefore.get(key) === packAfter.get(key)) unfollowed.push(lang); + else counters.followedPacks += 1; + } + if (unfollowed.length > 0) { + findings.push({ key, enBefore: previous, enAfter: value, unfollowed }); + } + } + + for (const key of enBefore.keys()) if (!enAfter.has(key)) counters.removedEnKeys += 1; + + return { findings, counters }; +} + +// -- the waiver ledger -------------------------------------------------------- + +/** @returns {{ waivers: Record }} */ +export function readLedger(root) { + const file = join(root, LEDGER_PATH); + if (!existsSync(file)) return { waivers: {} }; + const parsed = JSON.parse(readFileSync(file, 'utf8')); + return { waivers: parsed.waivers ?? {} }; +} + +/** + * Every way a ledger entry can be wrong, judged against the CURRENT `en` pack + * and nothing else — so it is decidable on any commit, with or without a diff. + * + * Note what is deliberately absent: "this waiver matched no finding" is not a + * problem. Every run after the waiving PR merges sees an empty diff, and making + * those runs red would hand one PR's escape hatch to every later PR as a red + * build. Quantity is ratcheted by the pin test instead. + */ +export function checkLedger(ledger, enAfter) { + /** @type {LedgerProblem[]} */ + const problems = []; + for (const [key, entry] of Object.entries(ledger.waivers)) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + problems.push({ reason: 'malformed-waiver', key, detail: 'entry is not an object' }); + continue; + } + if (typeof entry.en !== 'string') { + problems.push({ reason: 'malformed-waiver', key, detail: 'missing the `en` text this waiver covers' }); + } + if (typeof entry.reason !== 'string' || entry.reason.trim() === '') { + problems.push({ reason: 'malformed-waiver', key, detail: 'missing a `reason`' }); + } + if (typeof entry.issue !== 'string' || !ISSUE_PATTERN.test(entry.issue)) { + problems.push({ reason: 'malformed-waiver', key, detail: 'missing an `issue` of the form objectui#1234' }); + } + if (!enAfter.has(key)) { + problems.push({ reason: 'stale-waiver', key, detail: 'this key no longer exists in the en pack' }); + continue; + } + if (typeof entry.en === 'string' && entry.en !== enAfter.get(key)) { + problems.push({ + reason: 'stale-waiver', + key, + detail: + 'the en text this waiver covers is gone. Ledger says ' + + `${JSON.stringify(entry.en)}, en now says ${JSON.stringify(enAfter.get(key))}`, + }); + } + } + return problems; +} + +/** Splits findings into the ones a valid waiver covers and the ones it does not. */ +export function applyLedger(findings, ledger, enAfter) { + /** @type {Finding[]} */ + const unwaived = []; + /** @type {Finding[]} */ + const waived = []; + for (const finding of findings) { + const entry = ledger.waivers[finding.key]; + const covers = + entry !== undefined && + entry !== null && + typeof entry === 'object' && + entry.en === finding.enAfter && + // A waiver whose text no longer matches `en` is stale, and a stale waiver + // waives nothing — otherwise the expiry rule above would be advisory. + enAfter.get(finding.key) === entry.en; + (covers ? waived : unwaived).push(finding); + } + return { unwaived, waived }; +} + +// -- CLI ---------------------------------------------------------------------- + +/** + * Analysis for a repo root, given the two refs. `null` head = working tree. + * + * @param {string} root + * @param {{ base: string, head?: string | null }} options + */ +export function analyze(root, { base, head = null }) { + const before = readPacks(root, base); + const after = readPacks(root, head); + const { findings, counters } = findDrift(before, after); + return { before, after, findings, counters }; +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (invokedDirectly) { + const argOf = (name) => { + const index = process.argv.indexOf(name); + return index > -1 ? process.argv[index + 1] : null; + }; + + // `--root` points the gate at a different checkout — a worktree, or one of the + // throwaway repos `check-i18n-en-drift.test.ts` builds to exercise the exit + // codes end to end. `--base` / `--head` name the two commits explicitly; both + // default to the branch-vs-merge-base comparison CI runs. + const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + + const base = resolveBaseRef(root, { explicit: argOf('--base') }); + if (!base.ok) { + console.error( + 'Cannot resolve the commit to compare against, so there is nothing to diff.\n' + + ` tried: ${base.tried.join(', ')}\n` + + (base.shallow + ? ' This clone is SHALLOW. In CI, give the checkout `fetch-depth: 0`; locally,\n' + + ' run `git fetch --no-tags origin main` (or `git fetch --unshallow`).\n' + : ' Fetch the base branch (`git fetch --no-tags origin main`) and re-run.\n') + + ' This is a failure, not a skip: a diff gate with no diff would pass while\n' + + ' checking nothing. See the header of scripts/check-i18n-en-drift.mjs.', + ); + process.exit(1); + } + + const head = argOf('--head'); + const { after, findings, counters } = analyze(root, { base: base.ref, head }); + const enAfter = after.get('en'); + + // Guard against a refactor quietly emptying the comparison: with no keys, every + // assertion here is trivially satisfied. Same reason `all-locales-key-parity.test.ts` + // and `check-i18n-call-site-keys.mjs` both open with a size assertion. `--min-keys` + // exists because this gate's own tests run it over ten-key fixture packs; the + // DEFAULT is the number that matters and CI never passes the flag. + const minKeys = Number(argOf('--min-keys') ?? 2000); + const smallest = Math.min(...LOCALES.map((lang) => after.get(lang).size)); + if (smallest < minKeys) { + console.error( + `The scan collapsed: the smallest pack parsed to ${smallest} keys, expected at least ${minKeys}.` + + ' The extractor is broken, and an empty comparison would pass while asserting nothing.', + ); + process.exit(1); + } + + const ledger = readLedger(root); + const ledgerProblems = checkLedger(ledger, enAfter); + const { unwaived, waived } = applyLedger(findings, ledger, enAfter); + + console.log( + `Compared the ten locale packs at ${base.ref.slice(0, 9)} (${base.how}) with ` + + `${head ?? 'the working tree'}: ${counters.changedEnKeys} en value(s) changed ` + + `(${counters.addedEnKeys} key(s) added, ${counters.removedEnKeys} removed — those are ` + + `all-locales-key-parity's), ${counters.followedPacks} pack value(s) followed, ` + + `${counters.absentInPack} pack/key pair(s) skipped because the pack does not define the key, ` + + `${waived.length} finding(s) waived by the ledger.`, + ); + + if (unwaived.length === 0 && ledgerProblems.length === 0) { + console.log( + counters.changedEnKeys === 0 + ? 'No en value changed in this range.' + : waived.length === 0 + ? 'Every changed en value was followed by all nine translation packs.' + : `Every changed en value was followed by all nine translation packs, except ${waived.length} ` + + `covered by a waiver in ${LEDGER_PATH}: ${waived.map((f) => f.key).join(', ')}.`, + ); + process.exit(0); + } + + if (unwaived.length > 0) { + console.error( + `\n${unwaived.length} en string(s) changed without their translations:\n`, + ); + for (const finding of unwaived) { + console.error(` ${finding.key}`); + console.error(` en was: ${JSON.stringify(finding.enBefore)}`); + console.error(` en now: ${JSON.stringify(finding.enAfter)}`); + console.error(` unchanged in: ${finding.unfollowed.join(', ')}`); + } + console.error( + '\nTranslate the new text in each pack listed, IN THIS PR. The nine packs going one\n' + + 'release out of date is how objectui#3582 and objectui#3625 happened: eight packs kept\n' + + 'serving a retired sentence, one of them as an idiomatic translation that every other\n' + + 'gate reads as perfectly healthy. Nothing downstream can find it again.\n' + + `If a translation genuinely does not need to change, say so in ${LEDGER_PATH}:\n` + + ' { "": { "en": "", "reason": "…", "issue": "objectui#1234" } }\n' + + 'A waiver covers that one sentence and expires the next time the key changes.', + ); + } + + if (ledgerProblems.length > 0) { + console.error(`\n${ledgerProblems.length} problem(s) in ${LEDGER_PATH}:`); + for (const problem of ledgerProblems) { + console.error(` [${problem.reason}] ${problem.key}: ${problem.detail}`); + } + console.error( + '\nA waiver names the exact en text it covers, so it stops applying as soon as that text\n' + + 'changes — delete the entry, or renew it against the new sentence. The ledger only\n' + + 'shrinks on its own; growing it also means raising the ceiling in\n' + + 'scripts/__tests__/check-i18n-en-drift.test.ts, where a reviewer will see it.', + ); + } + + console.error('\nSee the header of scripts/check-i18n-en-drift.mjs.'); + process.exit(1); +} diff --git a/scripts/i18n-en-drift-baseline.json b/scripts/i18n-en-drift-baseline.json new file mode 100644 index 0000000000..2b764df08e --- /dev/null +++ b/scripts/i18n-en-drift-baseline.json @@ -0,0 +1,26 @@ +{ + "note": [ + "Waivers for scripts/check-i18n-en-drift.mjs (objectui#3650): an en string changed and one", + "or more of the nine translation packs deliberately did not follow it.", + "", + "Shape: \"\": { \"en\": \"\", \"reason\": \"…\", \"issue\": \"objectui#1234\" }", + "", + "This file ships EMPTY and holds no backlog, unlike scripts/i18n-call-site-key-baseline.json.", + "That gate measures a STATE (keys missing from en today), so it could inventory the debt. This", + "one judges an EVENT in a diff (en changed, the packs did not), and 'is ja's sentence still", + "what en's now means?' is the semantic question no gate can answer -- objectui#3625 was eight", + "idiomatic, native-script, mutually distinct translations of a retired sentence, green under", + "every mechanical judgement there is. So there is nothing to write down here at landing.", + "", + "An entry must transcribe the new en text, which is what makes it reviewable and what makes it", + "expire: the gate re-checks that text against the en pack on every run, so the waiver covers", + "that one sentence and stops applying the moment the key changes again. A waiver that fires", + "nothing is fine (every run after its PR merges sees an empty diff); a waiver whose text is", + "gone fails the build until it is deleted or renewed.", + "", + "Adding an entry also means raising WAIVER_CEILING in scripts/__tests__/check-i18n-en-drift.test.ts.", + "That is the ratchet: the ceiling is free to fall and costs a reviewed test edit to rise." + ], + + "waivers": {} +}