diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a79fbb0f..6a5b940a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,16 @@ jobs: - name: Verify spec-named symbols are derived, not hand-written run: pnpm check:spec-symbols + # A key a component asks `t()` for must exist in the `en` pack. + # `all-locales-key-parity.test.ts` compares packs to EACH OTHER, so ten + # packs identically missing a key is full parity and full parity is green + # — objectui#3517 lived there for months, and this gate's first full run + # found 258 more keys in the same blind spot. Same placement rationale as + # the step above: it imports `typescript` to parse the sources, so it + # needs the install, but nothing built. + - name: Verify t() call-site keys exist in the en locale pack + run: pnpm check:i18n-keys + # `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 519af9834..215c2de96 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "type-check:scripts": "tsc -p tsconfig.scripts.json", "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", "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/scripts/__tests__/check-i18n-call-site-keys.test.ts b/scripts/__tests__/check-i18n-call-site-keys.test.ts new file mode 100644 index 000000000..74a7ca893 --- /dev/null +++ b/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -0,0 +1,444 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + analyze, + applyBaseline, + collectEnKeys, + collectSourceFiles, + EXCLUDED_TRANSLATORS, + PACK_HOOK, +} from '../check-i18n-call-site-keys.mjs'; + +/** + * objectui#3530 — the behaviour test for `scripts/check-i18n-call-site-keys.mjs`. + * + * The gate answers "does the key this component asks for exist in `en`?", which + * `packages/i18n/src/__tests__/all-locales-key-parity.test.ts` structurally + * cannot: parity compares packs to each other, so ten packs identically missing + * a key is full parity and full parity is green. objectui#3517 lived in that + * blind spot for months; the gate's first full run found 258 more keys there. + * + * Two halves are pinned here, and they fail for different reasons: + * + * 1. The `en` key set is READ FROM AST, not imported, so the gate needs no + * build. That buys a second source of truth, and the first `describe` + * below is what stops it drifting: the parsed set must equal the set of + * the real module vitest evaluates. A parser that silently drops a subtree + * would make every key under it look missing (false red) — or, if it drops + * the whole literal, make the scan collapse to an empty comparison that + * passes while asserting nothing (the objectui#3009 shape). + * + * 2. Which `t` is being called. `t` is not one function in this repo: 2370 + * calls reach i18next, 1074 reach a module-local `engine.*` table, 41 are + * not translators at all. The synthetic-repo tests pin each classification + * independently of what today's `main` happens to contain. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const tempRoots: string[] = []; + +/** + * The real `en` pack, read the way the gate's own consumers read it: evaluated + * by the module loader, not parsed. + * + * Deliberately a COMPUTED dynamic import rather than + * `import realEn from '../../packages/i18n/src/locales/en'`. A static specifier + * would pull a 3.2k-line package source into `tsconfig.scripts.json`'s program, + * and that project's placement in `ci.yml` rests on the premise that it reads + * nothing outside `scripts/` — pinned by `scripts-type-check.test.ts`, whose + * regex only looks for workspace-package specifiers and would not have caught a + * relative one. Computing the path keeps the premise true instead of stepping + * around the pin that guards it. + */ +const realEn: unknown = ( + await import(pathToFileURL(path.join(repoRoot, 'packages/i18n/src/locales/en.ts')).href) +).default; + +/** + * Module specifiers that appear inside the FIXTURE SOURCES below — text to be + * analysed, not imports of this file. They are interpolated rather than written + * out because `scripts-type-check.test.ts` greps this directory for import + * statements naming a workspace package, to pin that the scripts project needs + * no workspace build, and its regex cannot tell a string literal from an import + * statement. (Nor a code comment from either — which is why this paragraph does + * not spell the pattern out.) + */ +const I18N_PKG = '@object-ui/i18n'; + +/** Dotted leaf paths of a plain object — the shape the gate compares against. */ +function leafPaths(node: unknown, prefix = ''): string[] { + return node !== null && typeof node === 'object' + ? Object.entries(node as Record).flatMap(([k, v]) => + leafPaths(v, prefix ? `${prefix}.${k}` : k), + ) + : [prefix]; +} + +/** Materialises `{ 'packages/x/src/a.tsx': '…' }` into a throwaway repo root. */ +function repoWith(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-i18n-keys-')); + tempRoots.push(root); + 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); + } + return root; +} + +/** A minimal `en` pack for the synthetic repos below. */ +const EN_FIXTURE = `const en = { + common: { save: 'Save', cancel: 'Cancel' }, + detail: { showEmptyRelated_one: '+ {{count}} empty', showEmptyRelated_other: '+ {{count}} empty' }, + grid: { column: { label: 'Label', width: 'Width' } }, +} as const; +export default en; +`; + +/** Findings of `reason` produced for a synthetic repo, as `key@file:line`. */ +function findingsOf(root: string, reason: string): string[] { + return analyze(root) + .findings.filter((f: { reason: string }) => f.reason === reason) + .map((f: { detail: string; file: string; line: number }) => `${f.detail}@${f.file}:${f.line}`) + .sort(); +} + +afterAll(() => { + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('the parsed en key set equals the module vitest actually evaluates', () => { + const parsed = collectEnKeys(repoRoot); + const runtime = new Set(leafPaths(realEn)); + + it('extracts exactly the real pack, key for key', () => { + const missed = [...runtime].filter((k) => !parsed.leaves.has(k)).sort(); + const invented = [...parsed.leaves].filter((k) => !runtime.has(k)).sort(); + expect(missed, `${missed.length} real key(s) the AST parser did not see`).toEqual([]); + expect(invented, `${invented.length} key(s) the AST parser invented`).toEqual([]); + }); + + it('is not a trivially-empty comparison', () => { + // Same reason all-locales-key-parity.test.ts opens with a size assertion: + // an extractor that returns nothing satisfies every assertion above. + expect(parsed.leaves.size).toBeGreaterThan(2000); + expect(parsed.branches.size).toBeGreaterThan(100); + }); + + it('records branches separately from leaves, for `returnObjects` lookups', () => { + expect(parsed.branches.has('common')).toBe(true); + expect(parsed.leaves.has('common')).toBe(false); + }); +}); + +describe('the file walk', () => { + it('reads sources but not tests, type declarations or build output', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/widget.tsx': 'export const a = 1;', + 'packages/x/src/widget.test.tsx': 'export const b = 1;', + 'packages/x/src/__tests__/thing.ts': 'export const c = 1;', + 'packages/x/src/types.d.ts': 'export type D = 1;', + 'packages/x/dist/widget.js': 'nope', + 'packages/x/node_modules/dep/index.ts': 'export const e = 1;', + 'apps/console/src/page.tsx': 'export const f = 1;', + 'apps/site/app/page.tsx': 'export const g = 1;', + }); + const files = collectSourceFiles(root).map((f: string) => path.relative(root, f).split(path.sep).join('/')); + expect(files).toEqual([ + 'apps/console/src/page.tsx', + 'apps/site/app/page.tsx', + 'packages/i18n/src/locales/en.ts', + 'packages/x/src/widget.tsx', + ]); + }); +}); + +describe('a key a pack-backed t() asks for must exist in en', () => { + it('reports the missing key with its file and line, and stays silent on the present one', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/Widget.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export function Widget() { + const { t } = useObjectTranslation(); + return [t('common.save'), t('common.reset', { defaultValue: 'Reset' })]; +} +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual(['common.reset@packages/x/src/Widget.tsx:4']); + }); + + it('an inline defaultValue does not make the key present — that is the bug, not the fix', () => { + // objectui#3517's whole failure mode: English renders at this one call site + // while all ten packs stay unable to translate it. + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = () => { const { t } = useObjectTranslation(); return t('form.createTargetOrg', { defaultValue: 'Create org' }); }; +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual(['form.createTargetOrg@packages/x/src/A.tsx:2']); + }); + + it('accepts a key the pack defines only in i18next plural forms', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = () => { const { t } = useObjectTranslation(); return t('detail.showEmptyRelated', { count: 2 }); }; +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual([]); + }); + + it('accepts a subtree key only when the call asks for `returnObjects`', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = () => { + const { t } = useObjectTranslation(); + return [t('grid.column', { returnObjects: true }), t('grid.column')]; +}; +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual(['grid.column@packages/x/src/A.tsx:4']); + }); + + it('reads every literal a call can denote: a chain array, a ternary, a cast', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useSafeTranslate } from '${I18N_PKG}'; +export const A = (flag: boolean) => { + const tt = useSafeTranslate(); + return [ + tt(['common.save', 'legacy.save'], 'Save'), + tt(flag ? 'common.cancel' : 'legacy.cancel', 'Cancel'), + tt('legacy.cast' as string, 'Cast'), + ]; +}; +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual([ + 'legacy.cancel@packages/x/src/A.tsx:6', + 'legacy.cast@packages/x/src/A.tsx:7', + 'legacy.save@packages/x/src/A.tsx:5', + ]); + }); +}); + +describe('dynamic keys: counted, never failed — except when the whole family is dead', () => { + it('a template key whose static head matches an en key is report-only', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (c: string) => { const { t } = useObjectTranslation(); return t(\`grid.column.\${c}\`); }; +`, + }); + const { findings, counters } = analyze(root); + expect(findings).toEqual([]); + expect(counters.dynamicKeySites).toBe(1); + }); + + it('a template key whose static head matches nothing fails: every expansion misses', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (c: string) => { const { t } = useObjectTranslation(); return t(\`gantt.linkEnd.\${c}\` as any); }; +`, + }); + expect(findingsOf(root, 'missing-prefix')).toEqual(['gantt.linkEnd.@packages/x/src/A.tsx:2']); + }); + + it('a fully computed key is counted and left alone — there is no head to judge', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (k: string) => { const { t } = useObjectTranslation(); return t(k); }; +`, + }); + const { findings, counters } = analyze(root); + expect(findings).toEqual([]); + expect(counters.dynamicKeySites).toBe(1); + }); +}); + +describe('the I18N_PROBE_FLAG exclusion is structural, not path-based', () => { + // `useObjectLabel` probes convention keys that are SUPPOSED to miss. Both of + // its real call sites use dynamic keys, so on today's `main` the flag only + // moves them out of the dynamic counter. This is the shape that makes the + // exclusion load-bearing, and it is pinned here rather than left to the next + // literal-key probe to discover. + const probeFile = `import { useObjectTranslation } from '${I18N_PKG}'; +import { I18N_PROBE_FLAG } from '${I18N_PKG}'; +export const A = () => { + const { t } = useObjectTranslation(); + return t('crm.objects.lead.label', { defaultValue: '', [I18N_PROBE_FLAG]: true }); +}; +`; + + it('skips a flagged literal-key probe wherever it is written', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/anything/src/Probe.tsx': probeFile, + }); + const { findings, counters } = analyze(root); + expect(findings).toEqual([]); + expect(counters.probeSites).toBe(1); + }); + + it('the very same call without the flag is reported', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/anything/src/Probe.tsx': probeFile.replace(', [I18N_PROBE_FLAG]: true', ''), + }); + expect(findingsOf(root, 'missing-key')).toEqual(['crm.objects.lead.label@packages/anything/src/Probe.tsx:5']); + }); +}); + +describe('which `t` is being called', () => { + it('skips the registered module-local table, and the components it hands `t` to', () => { + const localModule = EXCLUDED_TRANSLATORS[0].module; + const localScope = EXCLUDED_TRANSLATORS[0].forwardedScope[0]; + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + [localModule]: `export function t(key: string): string { return key; }\n`, + [`${localScope}Page.tsx`]: `import { t } from './i18n'; +export const Page = () => t('engine.directory.title'); +`, + [`${localScope}Child.tsx`]: `export const Child = ({ t }: { t: (key: string) => string }) => t('engine.edit.layers'); +`, + }); + const { findings, counters } = analyze(root); + expect(findings).toEqual([]); + expect(counters.skippedLocalTable).toBeGreaterThanOrEqual(2); + expect(counters.packCallSites).toBe(0); + }); + + it('fails on a `t` imported from a module nobody registered', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/labels.ts': `export function t(key: string): string { return key; }\n`, + 'packages/x/src/A.tsx': `import { t } from './labels'; +export const A = () => t('whatever.key'); +`, + }); + expect(findingsOf(root, 'unregistered-translator')).toEqual([ + 'packages/x/src/labels.ts@packages/x/src/A.tsx:2', + ]); + }); + + it('fails when createSafeTranslation is bound outside the hook-name convention', () => { + // The name IS the classification, so a factory bound to `copy` would take + // every call through it off the checked surface without a word. + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { createSafeTranslation } from '${I18N_PKG}'; +const copy = createSafeTranslation({}, 'common.save'); +export const A = () => { const { t } = copy(); return t('common.save'); }; +`, + }); + expect(findingsOf(root, 'unrecognised-hook')).toEqual(['copy@packages/x/src/A.tsx:2']); + expect(PACK_HOOK.test('copy')).toBe(false); + expect(['useObjectTranslation', 'useSafeTranslate', 'useKanbanT', 'useFieldTranslate'].every((n) => PACK_HOOK.test(n))).toBe(true); + }); + + it('ignores a local `t` that is not a translator at all', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.ts': `export const A = (start: number) => { + const t = () => Date.now() - start; + return t(); +}; +`, + }); + const { findings, counters } = analyze(root); + expect(findings).toEqual([]); + expect(counters.skippedNotATranslator).toBe(1); + }); + + it('resolves the nearest binding, so an inner shadow does not inherit the hook', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = () => { + const { t } = useObjectTranslation(); + const inner = () => { + const t = (n: number) => n * 2; + return t(21); + }; + return [t('legacy.outer'), inner()]; +}; +`, + }); + // Only the outer, hook-bound call is judged; `t(21)` is not a key at all. + expect(findingsOf(root, 'missing-key')).toEqual(['legacy.outer@packages/x/src/A.tsx:8']); + }); + + it('checks a translator forwarded into a helper module with no binding of its own', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_FIXTURE, + 'packages/x/src/helpers.ts': `type TranslateFn = (key: string) => string; +export function describe(t: TranslateFn): string { return t('legacy.helper'); } +`, + }); + expect(findingsOf(root, 'missing-key')).toEqual(['legacy.helper@packages/x/src/helpers.ts:2']); + }); +}); + +describe('the baseline is a ratchet', () => { + const finding = (reason: string, detail: string) => ({ reason, detail, file: 'f.tsx', line: 1, column: 1 }); + + it('lets a declared key through and stops an undeclared one', () => { + const baseline = { missingKeys: { 'known.gap': { issue: 'objectui#3546' } }, missingPrefixes: {} }; + const { unexpected, stale } = applyBaseline( + [finding('missing-key', 'known.gap'), finding('missing-key', 'brand.new')], + baseline, + ); + expect(unexpected.map((f: { detail: string }) => f.detail)).toEqual(['brand.new']); + expect(stale).toEqual([]); + }); + + it('fails on an entry whose defect is gone, so the file can only shrink', () => { + const baseline = { + missingKeys: { 'fixed.key': { issue: 'objectui#3546' } }, + missingPrefixes: { 'fixed.family.': { issue: 'objectui#3546' } }, + }; + const { unexpected, stale } = applyBaseline([], baseline); + expect(unexpected).toEqual([]); + expect(stale).toEqual([ + { kind: 'missingKeys', entry: 'fixed.key' }, + { kind: 'missingPrefixes', entry: 'fixed.family.' }, + ]); + }); + + it('the checked-in baseline is exactly what `main` still owes — no spare entries', () => { + // A stale entry here would mean the gate is carrying a fix that already + // landed, which is how a ratchet turns back into an allowlist. + const baselineFile = path.join(repoRoot, 'scripts/i18n-call-site-key-baseline.json'); + const baseline = JSON.parse(fs.readFileSync(baselineFile, 'utf8')); + const { unexpected, stale } = applyBaseline(analyze(repoRoot).findings, baseline); + expect(unexpected, `${unexpected.length} call site(s) not covered by the baseline`).toEqual([]); + expect(stale, `${stale.length} stale baseline entr(y|ies)`).toEqual([]); + for (const entry of Object.values(baseline.missingKeys) as Array<{ issue: string }>) { + expect(entry.issue).toMatch(/^objectui#\d+$/); + } + }); +}); + +describe('the gate is wired to run', () => { + it('package.json exposes it as a named script', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(pkg.scripts['check:i18n-keys']).toBe('node scripts/check-i18n-call-site-keys.mjs'); + }); + + it('ci.yml runs it after the install it needs (it imports typescript)', () => { + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + const install = ci.indexOf('pnpm install --frozen-lockfile'); + const step = ci.indexOf('pnpm check:i18n-keys'); + expect(step, 'ci.yml does not run `pnpm check:i18n-keys`').toBeGreaterThan(-1); + expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); + }); +}); diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs new file mode 100644 index 000000000..fa3eecb13 --- /dev/null +++ b/scripts/check-i18n-call-site-keys.mjs @@ -0,0 +1,729 @@ +#!/usr/bin/env node +/** + * Every key a component asks `t()` for must EXIST in the `en` locale pack. + * + * Run: node scripts/check-i18n-call-site-keys.mjs (also `pnpm check:i18n-keys`) + * Exit: 0 = every in-scope call-site key resolves (or is baselined), 1 = it does not + * + * ## The gap this closes (objectui#3530) + * + * `packages/i18n/src/__tests__/all-locales-key-parity.test.ts` asserts that every + * pack defines every `en` key and no pack defines a key `en` lacks — a **pack vs + * pack** invariant. It is blind by construction to the other direction: a key a + * component *references* that NO pack defines. Ten packs identically missing a + * key is full parity, and full parity is green. + * + * Nothing else caught it either, because `fallbackLng: 'en'` plus an inline + * `t(key, { defaultValue: 'English text' })` renders correct English at the one + * call site while the key stays untranslatable in all ten languages. The + * dev-only missing-key warner (`i18n.ts`) is the only runtime signal and CI + * never sees a browser console. objectui#3517 was one instance — + * `form.createTargetOrg`, missing from all ten packs, covered by an inline + * default for months, found only by #3469's by-hand per-key sweep. This gate is + * the class, not the instance: the first full run over `main` found + * 258 more. + * + * ## 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 + * wrong about roughly a third of them, because `t` is not one function: + * + * - 2370 calls reach i18next and therefore the locale packs. Those are the + * subject of this gate. + * - 1074 calls go to `packages/app-shell/src/views/metadata-admin/i18n.ts`, a + * module-local `engine.*` label table that is NOT an i18next pack and never + * will be (read its header). Checking its keys against `en` produces 89 + * permanent false reds — measured, by removing the exclusion. + * - 41 calls are a local `t` that is not a translator at all — a `useCallback` + * result, a `Date` difference, a `||` chain, a `.t()` method on some other + * object. + * + * So the unit of classification is the **binding**, not the spelling. For each + * `t(...)`/`tt(...)` call this file resolves which declaration of `t` is in + * scope at that position (nearest enclosing scope wins, then the latest + * declaration before the use), and classifies it: + * + * PACK — bound from a call to a translate hook: `const { t } = useXxx…()`, + * `const tt = useSafeTranslate()`. The hook-name convention is + * `use*Translation` / `use*Translate` / `use*T` and it is a real + * repo-wide convention: all 26 such hooks are either + * `createSafeTranslation(...)` factories or thin wrappers over + * `useObjectTranslation`. **Checked.** + * LOCAL — bound from an `import` of a module in EXCLUDED_TRANSLATORS below, + * or forwarded inside that module's declared scope. **Skipped**, by + * declaration, with a reason. An import from any *other* module is a + * hard error, so a second local table cannot appear silently. + * OTHER — anything else (`const t = someValue`). **Skipped**, counted. + * + * A `t` received as a parameter or a prop inherits its file's provenance: a + * helper that takes `t: TranslateFn` is checked in a file whose own `t` comes + * from a hook, and skipped inside the metadata-admin tree. That hop is the one + * place a type checker would be exact and this parser is a heuristic, so it is + * worth knowing what it buys: deleting `forwardedScope` puts 89 call sites + * (78 distinct `engine.*`/`perm.cel.*`/`perm.rls.*` keys) back on the report, + * every one of them a component that was handed the metadata-admin table's `t` + * by its parent, and not one of them a real finding. + * + * ## Two failure classes + * + * 1. `missing-key` — a literal key with no leaf in `en`. i18next plural suffixes + * (`_one`, `_other`, …) count as defining the base key, and a key passed with + * `returnObjects: true` may name a subtree rather than a leaf. + * 2. `missing-prefix` — a template key (`` t(`marketplace.category.${c}`) ``) + * whose static head matches NO `en` key at all. Then every possible expansion + * is missing, whatever the substitution evaluates to. This is the only claim + * about a dynamic key that is true without knowing the value. + * + * ## Dynamic keys: the explicit policy + * + * A key that is not a string literal cannot be resolved statically. Those call + * sites are **not checked and not failed** — they are COUNTED, and the count is + * printed on every run, so the unanalyzable surface is visible rather than + * silently absorbed. The `missing-prefix` class above recovers the part of it + * that can be decided. Same treatment, same reason, for the deliberate + * `I18N_PROBE_FLAG` misses (see below) and for the skipped binding classes. + * + * ## The probe exclusion + * + * `useObjectLabel` probes convention keys (`{ns}.objects.{name}.label`) that are + * SUPPOSED to miss — it falls back to the server-resolved label. Those calls + * carry `[I18N_PROBE_FLAG]: true`, and this gate excludes them by that flag, not + * by path, so a probe written anywhere is excluded and a non-probe call in + * `useObjectLabel.ts` is not. Both of today's probe sites happen to use dynamic + * keys, so the flag currently only moves them out of the dynamic counter; the + * exclusion is still load-bearing for the literal-key probe someone writes next, + * and `scripts/__tests__/check-i18n-call-site-keys.test.ts` pins that shape. + * + * ## The baseline + * + * `scripts/i18n-call-site-key-baseline.json` lists the keys already missing on + * `main` when this gate landed, each with the issue tracking its fix. It is a + * ratchet, not an allowlist: a key that is NOT in it fails, and an entry that no + * longer fires (key added to `en`, or its last call site deleted) ALSO fails, so + * the file can only shrink. Fixing the debt means adding the key to + * `packages/i18n/src/locales/en.ts` — which immediately makes + * `all-locales-key-parity.test.ts` demand it in the other nine packs, which is + * the correct order. + */ + +import ts from 'typescript'; +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { resolve, dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** Hook names whose `t` reaches i18next. See the header for why a name is enough. */ +export const PACK_HOOK = /^use[A-Za-z0-9_]*(Translation|Translate|T)$/; + +/** + * Annotations that mark a forwarded `t` as a translator. Only used to tell a + * forwarded translator apart from an unrelated `t` parameter; provenance (which + * table it reaches) comes from the file, not from the type. + */ +export const TRANSLATOR_TYPE = + /Translat|\bTFunction\b|\bTFn\b|\(\s*key\s*:|\(\s*keyOrKeys\s*:|\(\s*k\s*:\s*string/; + +/** The option flag `useObjectLabel` sets on its deliberate convention-key misses. */ +export const PROBE_FLAG_NAMES = /I18N_PROBE_FLAG|__ouiLabelProbe/; + +/** + * `t` bindings that do NOT resolve against the locale packs. Every entry is a + * decision with a reason; an imported `t` from anywhere else is a hard error + * (`unregistered-translator`) rather than a silent skip. + * + * `forwardedScope` is the directory whose files may receive this table's `t` as + * a parameter or prop. Without it, a component that takes `t` from a + * metadata-admin parent reads as pack-backed and its `engine.*` keys report as + * missing — 89 such false reds, measured, across `CelPredicateField.tsx`, + * `CelTestRunDialog.tsx`, `ConditionalFormattingEditor.tsx`, + * `PermissionAdvancedFacets.tsx` and `PermissionMatrixEditor.tsx`. + */ +export const EXCLUDED_TRANSLATORS = [ + { + module: 'packages/app-shell/src/views/metadata-admin/i18n.ts', + forwardedScope: ['packages/app-shell/src/views/metadata-admin/'], + reason: + 'module-local engine.* label table (a plain Record lookup, not i18next); ' + + 'its keys are not in any locale pack by design — see that file\'s header', + }, +]; + +/** Directories never scanned: build output, deps, and test/mock trees. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.next', '.turbo', '__mocks__']); +const TEST_FILE = /(^|[/\\])__tests__[/\\]|\.test\.tsx?$|\.spec\.tsx?$/; + +/** i18next resolves `key` when the pack defines any of these plural forms. */ +const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']; + +// ── the `en` pack ──────────────────────────────────────────────────────────── + +/** + * Dotted leaf paths of `packages/i18n/src/locales/en.ts`, read from its AST. + * + * Parsed rather than imported so the gate needs no build step and no TS loader. + * `scripts/__tests__/check-i18n-call-site-keys.test.ts` pins this extraction + * against the real module evaluated by vitest, so the two cannot drift. + * + * @returns {{ leaves: Set, branches: Set }} + */ +export function collectEnKeys(root) { + const file = join(root, 'packages/i18n/src/locales/en.ts'); + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); + + const unwrap = (node) => { + let n = node; + while (ts.isAsExpression(n) || ts.isParenthesizedExpression(n) || (ts.isSatisfiesExpression?.(n) ?? false)) n = n.expression; + return n; + }; + + let literal = null; + const findEn = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'en' && + node.initializer + ) { + const init = unwrap(node.initializer); + if (ts.isObjectLiteralExpression(init)) literal = init; + } + ts.forEachChild(node, findEn); + }; + findEn(source); + if (!literal) throw new Error(`cannot find the \`const en = { … }\` object literal in ${file}`); + + const leaves = new Set(); + const branches = new Set(); + const walk = (object, prefix) => { + for (const prop of object.properties) { + if (!ts.isPropertyAssignment(prop)) { + throw new Error(`unsupported property form in ${file} 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(`unsupported key form in ${file} at ${prefix || ''}`); + } + const path = prefix ? `${prefix}.${name}` : name; + const value = unwrap(prop.initializer); + if (ts.isObjectLiteralExpression(value)) { + branches.add(path); + walk(value, path); + } else { + leaves.add(path); + } + } + }; + walk(literal, ''); + return { leaves, branches }; +} + +// ── source walk ────────────────────────────────────────────────────────────── + +/** Every non-test `.ts`/`.tsx` file under `packages/` and `apps/`. */ +export function collectSourceFiles(root) { + const out = []; + const walk = (dir) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts') && !TEST_FILE.test(full)) out.push(full); + } + }; + for (const top of ['packages', 'apps']) { + const dir = join(root, top); + if (existsSync(dir)) walk(dir); + } + return out.sort(); +} + +/** The scope node a declaration belongs to (function body, block, file, …). */ +function enclosingScope(node) { + let current = node.parent; + while (current) { + if ( + ts.isSourceFile(current) || + ts.isBlock(current) || + ts.isModuleBlock(current) || + ts.isCaseBlock(current) || + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isMethodDeclaration(current) || + ts.isForStatement(current) || + ts.isForOfStatement(current) || + ts.isForInStatement(current) + ) { + return current; + } + current = current.parent; + } + return null; +} + +/** Resolve a relative import specifier to a repo-relative file path. */ +function resolveImport(root, fromFile, specifier) { + if (!specifier.startsWith('.')) return specifier; + const base = resolve(dirname(fromFile), specifier); + for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx', '']) { + const candidate = base + suffix; + if (existsSync(candidate) && statSync(candidate).isFile()) return relative(root, candidate); + } + return relative(root, base); +} + +/** + * Collect every declaration of `t`/`tt` in a file, with the shape that produced + * it. Kinds: `packHook`, `import`, `localFunction`, `forwarded`, `other`. + */ +function collectBindings(root, file, source) { + const bindings = []; + const add = (name, declaration, kind, detail) => + bindings.push({ + name, + kind, + detail, + scope: enclosingScope(declaration) ?? source, + pos: declaration.getStart(source), + }); + const named = (name) => name === 't' || name === 'tt'; + + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) + ) { + const specifier = node.moduleSpecifier.getText(source).slice(1, -1); + for (const element of node.importClause.namedBindings.elements) { + if (named(element.name.text)) { + add(element.name.text, node, 'import', resolveImport(root, file, specifier)); + } + } + } + + if (ts.isFunctionDeclaration(node) && node.name && named(node.name.text)) { + add(node.name.text, node, 'localFunction', 'declared in this file'); + } + + if (ts.isVariableDeclaration(node) && node.initializer) { + let init = node.initializer; + while (ts.isAsExpression(init) || ts.isParenthesizedExpression(init) || ts.isNonNullExpression(init)) { + init = init.expression; + } + let kind = 'other'; + let detail = ts.SyntaxKind[init.kind]; + if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) { + detail = `${init.expression.text}()`; + if (PACK_HOOK.test(init.expression.text)) kind = 'packHook'; + } else if (ts.isCallExpression(init)) { + detail = `${init.expression.getText(source)}()`; + } else if (ts.isIdentifier(init)) { + // `const { t } = props` — a forwarded translator, same hop as a parameter. + kind = 'forwarded'; + detail = `destructured from \`${init.text}\``; + } + + if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + const property = element.propertyName ? element.propertyName.getText(source) : element.name.getText(source); + if (property !== 't' && property !== 'tt') continue; + if (!ts.isIdentifier(element.name)) continue; + add(element.name.text, node, kind === 'other' ? 'other' : kind, detail); + } + } else if (ts.isIdentifier(node.name) && named(node.name.text)) { + add(node.name.text, node, kind, detail); + } + } + + if (ts.isParameter(node)) { + if (ts.isIdentifier(node.name) && named(node.name.text)) { + const annotation = node.type ? node.type.getText(source).replace(/\s+/g, ' ') : ''; + add(node.name.text, node, TRANSLATOR_TYPE.test(annotation) ? 'forwarded' : 'other', `parameter: ${annotation || '(untyped)'}`); + } else if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + const property = element.propertyName ? element.propertyName.getText(source) : element.name.getText(source); + if (property !== 't' && property !== 'tt') continue; + if (!ts.isIdentifier(element.name)) continue; + // A destructured prop: prefer the member's own type off an inline + // type literal, else the whole annotation (`SomeProps`). + let annotation = node.type ? node.type.getText(source).replace(/\s+/g, ' ') : ''; + if (node.type && ts.isTypeLiteralNode(node.type)) { + for (const member of node.type.members) { + if (ts.isPropertySignature(member) && member.name.getText(source) === property && member.type) { + annotation = member.type.getText(source).replace(/\s+/g, ' '); + } + } + } + add(element.name.text, node, 'forwarded', `prop: ${annotation || '(untyped)'}`); + } + } + } + + ts.forEachChild(node, visit); + }; + visit(source); + return bindings; +} + +/** Nearest binding of `name` visible at `position`: deepest scope, then latest declaration. */ +function bindingAt(bindings, source, name, position) { + let best = null; + for (const binding of bindings) { + if (binding.name !== name) continue; + if (!binding.scope) continue; + if (position < binding.scope.getStart(source) || position > binding.scope.getEnd()) continue; + if (!best) { + best = binding; + continue; + } + const deeper = binding.scope.getStart(source) > best.scope.getStart(source); + const sameScope = binding.scope === best.scope; + if (deeper) best = binding; + else if (sameScope && binding.pos <= position && (best.pos > position || binding.pos > best.pos)) best = binding; + } + return best; +} + +/** Strip `as`/parenthesis wrappers, which callers use to satisfy the key type. */ +function unwrapExpression(node) { + let current = node; + while (current && (ts.isAsExpression(current) || ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current))) { + current = current.expression; + } + return current; +} + +/** Literal keys a first argument denotes, plus whether any part of it is dynamic. */ +function literalKeysOf(argument, source) { + const keys = []; + let dynamic = false; + const read = (node) => { + if (!node) { + dynamic = true; + return; + } + const inner = unwrapExpression(node); + if (inner !== node) { + read(inner); + return; + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + keys.push(node.text); + } else if (ts.isArrayLiteralExpression(node)) { + // `tt(['common.total', 'dashboard.total'], 'Total')` — a migration chain. + for (const element of node.elements) read(element); + } else if (ts.isConditionalExpression(node)) { + read(node.whenTrue); + read(node.whenFalse); + } else { + dynamic = true; + } + }; + read(argument); + return { keys, dynamic }; +} + +/** The literal head of a template key, i.e. everything before the first `${`. */ +function staticHead(argument) { + const inner = unwrapExpression(argument); + // `t(`ns.${x}` as any)` is the same key shape as `t(`ns.${x}`)`; the cast is + // there to satisfy a key type, and reading through it is what found the fifth + // dead template family (`marketplace.disclosure.runtime.`). + if (!inner || !ts.isTemplateExpression(inner)) return ''; + return inner.head.text; +} + +// ── the analysis ───────────────────────────────────────────────────────────── + +/** + * @returns {{ findings: Array, counters: Record, enKeyCount: number }} + */ +export function analyze(root) { + const { leaves, branches } = collectEnKeys(root); + const resolvesLeaf = (key) => leaves.has(key) || PLURAL_SUFFIXES.some((suffix) => leaves.has(key + suffix)); + // Materialised once, not inside the predicate: spreading a 2.6k-entry Set per + // candidate head is the shape that made `all-locales-key-parity` quadratic + // (7.51s -> 25ms once hoisted; see AGENTS.md 测试纪律). + const everyPath = [...leaves, ...branches]; + const headMatches = (head) => everyPath.some((key) => key.startsWith(head)); + + const registeredModules = new Set(EXCLUDED_TRANSLATORS.map((entry) => entry.module)); + const localScopes = EXCLUDED_TRANSLATORS.flatMap((entry) => entry.forwardedScope ?? []); + + const findings = []; + const counters = { + filesScanned: 0, + callSites: 0, + packCallSites: 0, + literalKeys: 0, + resolvedKeys: 0, + dynamicKeySites: 0, + probeSites: 0, + skippedLocalTable: 0, + skippedNotATranslator: 0, + skippedMethodCall: 0, + }; + + for (const file of collectSourceFiles(root)) { + counters.filesScanned += 1; + const text = readFileSync(file, 'utf8'); + const relPath = relative(root, file).split('\\').join('/'); + const isLocalTableFile = registeredModules.has(relPath); + + // `createSafeTranslation` is the factory every pack-backed hook in this repo + // is built from. If one is bound to a name this gate would not recognise as + // a hook, every call through it silently leaves the checked surface — so say + // so instead of skipping it. + for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*createSafeTranslation\s*\(/g)) { + if (!PACK_HOOK.test(match[1])) { + const line = text.slice(0, match.index).split('\n').length; + findings.push({ + reason: 'unrecognised-hook', + file: relPath, + line, + column: 1, + detail: match[1], + }); + } + } + + if (!/\bt\s*\(|\btt\s*\(/.test(text)) continue; + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const bindings = collectBindings(root, file, source); + + // A file's provenance: which table its own `t` reaches. A forwarded `t` + // inherits it, because the parser cannot follow the value across modules. + let provenance = localScopes.some((dir) => relPath.startsWith(dir)) || isLocalTableFile ? 'local' : null; + for (const binding of bindings) { + if (binding.kind === 'packHook' && provenance === null) provenance = 'pack'; + if (binding.kind === 'import' && registeredModules.has(binding.detail)) provenance = 'local'; + } + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + let name = null; + let isMethod = false; + if (ts.isIdentifier(callee)) name = callee.text; + else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name) && callee.name.text === 't') { + name = 't'; + isMethod = true; + } + + if (name === 't' || name === 'tt') { + counters.callSites += 1; + const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source)); + const at = { file: relPath, line: line + 1, column: character + 1 }; + + if (isMethod) { + // `someObject.t(...)` — the receiver's type is unknowable here. + counters.skippedMethodCall += 1; + ts.forEachChild(node, visit); + return; + } + + const binding = bindingAt(bindings, source, name, node.getStart(source)); + const kind = binding ? binding.kind : 'forwarded'; // no declaration in this file = it came from outside + + if (kind === 'import' && !registeredModules.has(binding.detail)) { + findings.push({ reason: 'unregistered-translator', ...at, detail: binding.detail }); + ts.forEachChild(node, visit); + return; + } + if (kind === 'import' || kind === 'localFunction' || (kind === 'forwarded' && provenance === 'local')) { + counters.skippedLocalTable += 1; + ts.forEachChild(node, visit); + return; + } + if (kind === 'other') { + counters.skippedNotATranslator += 1; + ts.forEachChild(node, visit); + return; + } + + // PACK: `packHook`, or a forwarded translator in a non-local file. + counters.packCallSites += 1; + + let isProbe = false; + let returnsObjects = false; + for (const argument of node.arguments.slice(1)) { + if (!ts.isObjectLiteralExpression(argument)) continue; + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) continue; + if (ts.isComputedPropertyName(property.name) && PROBE_FLAG_NAMES.test(property.name.expression.getText(source))) { + isProbe = true; + } + if ( + ts.isIdentifier(property.name) && + property.name.text === 'returnObjects' && + property.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + returnsObjects = true; + } + } + } + if (isProbe) { + // Deliberate convention-key miss — excluded by the flag, not by path. + counters.probeSites += 1; + ts.forEachChild(node, visit); + return; + } + + const argument = node.arguments[0]; + const { keys, dynamic } = literalKeysOf(argument, source); + for (const key of keys) { + counters.literalKeys += 1; + if (resolvesLeaf(key) || (returnsObjects && branches.has(key))) counters.resolvedKeys += 1; + else findings.push({ reason: 'missing-key', ...at, detail: key }); + } + if (dynamic) { + counters.dynamicKeySites += 1; + const head = staticHead(argument); + if (head && !headMatches(head)) { + findings.push({ reason: 'missing-prefix', ...at, detail: head }); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + + return { findings, counters, enKeyCount: leaves.size }; +} + +// ── baseline ───────────────────────────────────────────────────────────────── + +export function readBaseline(root) { + const file = join(root, 'scripts/i18n-call-site-key-baseline.json'); + if (!existsSync(file)) return { missingKeys: {}, missingPrefixes: {} }; + const parsed = JSON.parse(readFileSync(file, 'utf8')); + return { missingKeys: parsed.missingKeys ?? {}, missingPrefixes: parsed.missingPrefixes ?? {} }; +} + +/** + * Split findings against the baseline. `unexpected` fails the build; `stale` + * fails it too — a baseline entry whose defect is gone must be deleted, so the + * file can only shrink. + */ +export function applyBaseline(findings, baseline) { + const unexpected = []; + const seenKeys = new Set(); + const seenPrefixes = new Set(); + + for (const finding of findings) { + if (finding.reason === 'missing-key' && Object.hasOwn(baseline.missingKeys, finding.detail)) { + seenKeys.add(finding.detail); + continue; + } + if (finding.reason === 'missing-prefix' && Object.hasOwn(baseline.missingPrefixes, finding.detail)) { + seenPrefixes.add(finding.detail); + continue; + } + unexpected.push(finding); + } + + const stale = [ + ...Object.keys(baseline.missingKeys).filter((key) => !seenKeys.has(key)).map((key) => ({ kind: 'missingKeys', entry: key })), + ...Object.keys(baseline.missingPrefixes).filter((p) => !seenPrefixes.has(p)).map((entry) => ({ kind: 'missingPrefixes', entry })), + ]; + + return { unexpected, stale }; +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +const HINTS = { + 'missing-key': + 'The key exists in no locale pack. Add it to `packages/i18n/src/locales/en.ts`' + + ' — that is the source of truth, and adding it there makes' + + ' `packages/i18n/src/__tests__/all-locales-key-parity.test.ts` demand the same key' + + ' in the other nine packs, which is the point. An inline' + + ' `t(key, { defaultValue: "…" })` is NOT a fix: it renders English at this one' + + ' call site and leaves the string untranslatable everywhere (objectui#3517).', + 'missing-prefix': + 'No key in `en` begins with this template literal\'s static head, so every value the' + + ' substitution can take is missing. Add the whole family to' + + ' `packages/i18n/src/locales/en.ts`.', + 'unregistered-translator': + 'This file imports a `t` from a module this gate does not know. If that module is a' + + ' pack-backed re-export, it should be called through a `use*Translation` hook so its' + + ' keys are checked; if it is a module-local label table like' + + ' `packages/app-shell/src/views/metadata-admin/i18n.ts`, register it in' + + ' EXCLUDED_TRANSLATORS with a reason.', + 'unrecognised-hook': + '`createSafeTranslation(...)` bound to a name outside the `use*Translation` /' + + ' `use*Translate` / `use*T` convention. Every call through it would leave this' + + ' gate\'s checked surface silently — rename it to the convention.', +}; + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (invokedDirectly) { + const root = resolve(scriptDir, '..'); + const { findings, counters, enKeyCount } = analyze(root); + + // Guard against a refactor quietly emptying the comparison: with no keys and + // no call sites every assertion below is trivially satisfied. Same reason + // `all-locales-key-parity.test.ts` opens with a size assertion. + if (enKeyCount < 2000 || counters.packCallSites < 1000) { + console.error( + `The scan collapsed: ${enKeyCount} en keys and ${counters.packCallSites} pack-backed call sites.` + + ' Expected thousands of both — the extractor or the file walk is broken, and an' + + ' empty comparison would pass while asserting nothing.', + ); + process.exit(1); + } + + const { unexpected, stale } = applyBaseline(findings, readBaseline(root)); + + console.log( + `Scanned ${counters.filesScanned} files, ${counters.callSites} t()/tt() call sites: ` + + `${counters.packCallSites} pack-backed (${counters.resolvedKeys}/${counters.literalKeys} literal keys resolve), ` + + `${counters.dynamicKeySites} dynamic-key (report-only), ${counters.probeSites} probe-flagged, ` + + `${counters.skippedLocalTable} module-local table, ${counters.skippedNotATranslator} not a translator, ` + + `${counters.skippedMethodCall} method call.`, + ); + + if (unexpected.length === 0 && stale.length === 0) { + console.log(`Every in-scope call-site key resolves against the en pack (${enKeyCount} keys).`); + process.exit(0); + } + + if (unexpected.length > 0) { + const distinct = new Set(unexpected.map((f) => `${f.reason} :: ${f.detail}`)); + console.error( + `\n${unexpected.length} call site${unexpected.length === 1 ? '' : 's'} reference${unexpected.length === 1 ? 's' : ''} ` + + `a key the en pack does not define (${distinct.size} distinct):`, + ); + for (const finding of unexpected) { + console.error(` ${finding.file}:${finding.line}:${finding.column} [${finding.reason}] ${finding.detail}`); + } + for (const reason of Object.keys(HINTS)) { + if (unexpected.some((finding) => finding.reason === reason)) console.error(`\n${reason}: ${HINTS[reason]}`); + } + } + + if (stale.length > 0) { + console.error( + `\n${stale.length} baseline entr${stale.length === 1 ? 'y is' : 'ies are'} stale — the defect is gone, so the` + + ' entry must go too (this file is a ratchet; it only shrinks):', + ); + for (const entry of stale) console.error(` ${entry.kind}: ${entry.entry}`); + } + + console.error('\nSee the header of scripts/check-i18n-call-site-keys.mjs.'); + process.exit(1); +} diff --git a/scripts/i18n-call-site-key-baseline.json b/scripts/i18n-call-site-key-baseline.json new file mode 100644 index 000000000..731317bb6 --- /dev/null +++ b/scripts/i18n-call-site-key-baseline.json @@ -0,0 +1,280 @@ +{ + "note": [ + "Keys a t() call site references that exist in NO locale pack, measured on main@a2c8f2a29.", + "A RATCHET, not an allowlist: a key missing from this file fails the build, and an", + "entry whose defect is gone fails it too, so the file can only shrink.", + "Fix one by adding the key to packages/i18n/src/locales/en.ts and deleting its line", + "here; all-locales-key-parity.test.ts then demands the same key in the other nine", + "packs. Adding an inline defaultValue is NOT a fix -- that is the mechanism that hid", + "these for months (objectui#3517)." + ], + + "missingKeys": { + "acceptInvitation.accept": { "issue": "objectui#3546" }, + "acceptInvitation.acceptFailed": { "issue": "objectui#3546" }, + "acceptInvitation.accepted": { "issue": "objectui#3546" }, + "acceptInvitation.accepting": { "issue": "objectui#3546" }, + "acceptInvitation.decline": { "issue": "objectui#3546" }, + "acceptInvitation.declineFailed": { "issue": "objectui#3546" }, + "acceptInvitation.declined": { "issue": "objectui#3546" }, + "acceptInvitation.declining": { "issue": "objectui#3546" }, + "acceptInvitation.description": { "issue": "objectui#3546" }, + "acceptInvitation.invalidDescription": { "issue": "objectui#3546" }, + "acceptInvitation.invalidTitle": { "issue": "objectui#3546" }, + "acceptInvitation.title": { "issue": "objectui#3546" }, + "auth.device.disabledDescription": { "issue": "objectui#3546" }, + "auth.device.disabledTitle": { "issue": "objectui#3546" }, + "auth.forgotPassword.newPasswordLabel": { "issue": "objectui#3546" }, + "auth.forgotPassword.newPasswordPlaceholder": { "issue": "objectui#3546" }, + "auth.forgotPassword.otpCodeLabel": { "issue": "objectui#3546" }, + "auth.forgotPassword.otpCodePlaceholder": { "issue": "objectui#3546" }, + "auth.forgotPassword.phoneLabel": { "issue": "objectui#3546" }, + "auth.forgotPassword.phonePlaceholder": { "issue": "objectui#3546" }, + "auth.forgotPassword.phoneSuccessDescription": { "issue": "objectui#3546" }, + "auth.forgotPassword.phoneSuccessTitle": { "issue": "objectui#3546" }, + "auth.forgotPassword.resendOtpCountdownText": { "issue": "objectui#3546" }, + "auth.forgotPassword.resetButton": { "issue": "objectui#3546" }, + "auth.forgotPassword.sendOtpButton": { "issue": "objectui#3546" }, + "auth.forgotPassword.useEmailResetText": { "issue": "objectui#3546" }, + "auth.forgotPassword.usePhoneResetText": { "issue": "objectui#3546" }, + "auth.login.emailOrPhoneLabel": { "issue": "objectui#3546" }, + "auth.login.emailOrPhonePlaceholder": { "issue": "objectui#3546" }, + "auth.login.otpCodeLabel": { "issue": "objectui#3546" }, + "auth.login.otpCodePlaceholder": { "issue": "objectui#3546" }, + "auth.login.phoneLabel": { "issue": "objectui#3546" }, + "auth.login.phonePlaceholder": { "issue": "objectui#3546" }, + "auth.login.resendOtpCountdownText": { "issue": "objectui#3546" }, + "auth.login.sendOtpButton": { "issue": "objectui#3546" }, + "auth.login.usePasswordSignInText": { "issue": "objectui#3546" }, + "auth.login.usePhoneOtpText": { "issue": "objectui#3546" }, + "auth.verifyEmail.resendUnavailable": { "issue": "objectui#3546" }, + "common.done": { "issue": "objectui#3546" }, + "common.editInStudio": { "issue": "objectui#3546" }, + "common.record": { "issue": "objectui#3546" }, + "common.retry": { "issue": "objectui#3546" }, + "console.ai.collapseToDock": { "issue": "objectui#3546" }, + "console.ai.connectionOffline": { "issue": "objectui#3546" }, + "console.ai.connectionStalled": { "issue": "objectui#3546" }, + "console.ai.connectionWaiting": { "issue": "objectui#3546" }, + "console.ai.designingPlan": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.dashboard": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.data": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.defaults": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.finalize": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.forms": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.lookups": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.objects": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.relations": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.review": { "issue": "objectui#3546" }, + "console.ai.designingPlanHint.views": { "issue": "objectui#3546" }, + "console.ai.dock.collapse": { "issue": "objectui#3546" }, + "console.ai.dock.description": { "issue": "objectui#3546" }, + "console.ai.dock.maximize": { "issue": "objectui#3546" }, + "console.ai.dock.open": { "issue": "objectui#3546" }, + "console.ai.dock.resize": { "issue": "objectui#3546" }, + "console.ai.dock.title": { "issue": "objectui#3546" }, + "console.ai.newApp": { "issue": "objectui#3546" }, + "console.ai.planBuilding": { "issue": "objectui#3546" }, + "console.ai.planBuilt": { "issue": "objectui#3546" }, + "console.ai.planDeferred": { "issue": "objectui#3546" }, + "console.ai.planReady": { "issue": "objectui#3546" }, + "console.ai.publishFailed": { "issue": "objectui#3546" }, + "console.ai.published": { "issue": "objectui#3546" }, + "console.ai.switchApp": { "issue": "objectui#3546" }, + "console.ai.switchAppLabel": { "issue": "objectui#3546" }, + "console.ai.unavailableDescription": { "issue": "objectui#3546" }, + "console.ai.unavailableError": { "issue": "objectui#3546" }, + "console.ai.unavailableHome": { "issue": "objectui#3546" }, + "console.ai.unavailableRetry": { "issue": "objectui#3546" }, + "console.ai.unavailableTitle": { "issue": "objectui#3546" }, + "console.notFound.back": { "issue": "objectui#3546" }, + "console.notFound.description": { "issue": "objectui#3546" }, + "console.notFound.title": { "issue": "objectui#3546" }, + "console.objectView.cannotDeleteMetaView": { "issue": "objectui#3546" }, + "console.objectView.cannotEditMetaView": { "issue": "objectui#3546" }, + "console.shortcuts.groups.aiChat": { "issue": "objectui#3546" }, + "console.shortcuts.newChat": { "issue": "objectui#3546" }, + "console.shortcuts.toggleChatsList": { "issue": "objectui#3546" }, + "dashboard.loading": { "issue": "objectui#3546" }, + "detail.add": { "issue": "objectui#3546" }, + "detail.concurrentUpdateRecordLabel": { "issue": "objectui#3546" }, + "detail.deleted": { "issue": "objectui#3546" }, + "detail.historyEmpty": { "issue": "objectui#3546" }, + "detail.viewSource": { "issue": "objectui#3546" }, + "empty.appNotAvailable": { "issue": "objectui#3546" }, + "empty.appNotAvailableDescription": { "issue": "objectui#3546" }, + "empty.interfacePageSourceMissing": { "issue": "objectui#3546" }, + "gantt.toolbar.refresh": { "issue": "objectui#3546" }, + "home.pendingDrafts.capabilityWarn": { "issue": "objectui#3546" }, + "home.pendingDrafts.nothing": { "issue": "objectui#3546" }, + "home.pendingDrafts.probeWarn": { "issue": "objectui#3546" }, + "home.pendingDrafts.publishedVerified": { "issue": "objectui#3546" }, + "home.pendingDrafts.seedWarn": { "issue": "objectui#3546" }, + "kanban.columns": { "issue": "objectui#3546" }, + "layout.systemNav.administration": { "issue": "objectui#3546" }, + "layout.systemNav.datasources": { "issue": "objectui#3546" }, + "layout.systemNav.documentation": { "issue": "objectui#3546" }, + "marketplace.action.updateTo": { "issue": "objectui#3546" }, + "marketplace.disclosure.acknowledge": { "issue": "objectui#3546" }, + "marketplace.disclosure.containsCode": { "issue": "objectui#3546" }, + "marketplace.disclosure.fs": { "issue": "objectui#3546" }, + "marketplace.disclosure.grantsIntro": { "issue": "objectui#3546" }, + "marketplace.disclosure.hooks": { "issue": "objectui#3546" }, + "marketplace.disclosure.network": { "issue": "objectui#3546" }, + "marketplace.disclosure.noPermissions": { "issue": "objectui#3546" }, + "marketplace.disclosure.reviewed": { "issue": "objectui#3546" }, + "marketplace.disclosure.services": { "issue": "objectui#3546" }, + "marketplace.disclosure.signed": { "issue": "objectui#3546" }, + "marketplace.disclosure.unreviewed": { "issue": "objectui#3546" }, + "marketplace.install.installedVersion": { "issue": "objectui#3546" }, + "marketplace.install.updateTo": { "issue": "objectui#3546" }, + "marketplace.org.heading": { "issue": "objectui#3546" }, + "marketplace.org.install": { "issue": "objectui#3546" }, + "marketplace.org.installed": { "issue": "objectui#3546" }, + "marketplace.org.installedBadge": { "issue": "objectui#3546" }, + "marketplace.org.installing": { "issue": "objectui#3546" }, + "oauth.consent.authorize": { "issue": "objectui#3546" }, + "oauth.consent.denied": { "issue": "objectui#3546" }, + "oauth.consent.deny": { "issue": "objectui#3546" }, + "oauth.consent.failed": { "issue": "objectui#3546" }, + "oauth.consent.footer": { "issue": "objectui#3546" }, + "oauth.consent.granted": { "issue": "objectui#3546" }, + "oauth.consent.noRedirect": { "issue": "objectui#3546" }, + "oauth.consent.request": { "issue": "objectui#3546" }, + "oauth.consent.scope.email": { "issue": "objectui#3546" }, + "oauth.consent.scope.offlineAccess": { "issue": "objectui#3546" }, + "oauth.consent.scope.openid": { "issue": "objectui#3546" }, + "oauth.consent.scope.profile": { "issue": "objectui#3546" }, + "oauth.consent.submitting": { "issue": "objectui#3546" }, + "oauth.consent.title": { "issue": "objectui#3546" }, + "oauth.consent.unknownApp": { "issue": "objectui#3546" }, + "oauth.consent.willAllow": { "issue": "objectui#3546" }, + "organization.accept.accept": { "issue": "objectui#3546" }, + "organization.accept.acceptFailed": { "issue": "objectui#3546" }, + "organization.accept.accepted": { "issue": "objectui#3546" }, + "organization.accept.decline": { "issue": "objectui#3546" }, + "organization.accept.declineFailed": { "issue": "objectui#3546" }, + "organization.accept.declined": { "issue": "objectui#3546" }, + "organization.accept.description": { "issue": "objectui#3546" }, + "organization.accept.errorTitle": { "issue": "objectui#3546" }, + "organization.accept.expiresAt": { "issue": "objectui#3546" }, + "organization.accept.goToOrgs": { "issue": "objectui#3546" }, + "organization.accept.loading": { "issue": "objectui#3546" }, + "organization.accept.organization": { "issue": "objectui#3546" }, + "organization.accept.role": { "issue": "objectui#3546" }, + "organization.accept.title": { "issue": "objectui#3546" }, + "organization.backToList": { "issue": "objectui#3546" }, + "organization.invitations.businessUnitLabel": { "issue": "objectui#3546" }, + "organization.invitations.businessUnitPlaceholder": { "issue": "objectui#3546" }, + "organization.invitations.cancelAction": { "issue": "objectui#3546" }, + "organization.invitations.cancelDescription": { "issue": "objectui#3546" }, + "organization.invitations.cancelFailed": { "issue": "objectui#3546" }, + "organization.invitations.cancelTitle": { "issue": "objectui#3546" }, + "organization.invitations.canceled": { "issue": "objectui#3546" }, + "organization.invitations.copyFailed": { "issue": "objectui#3546" }, + "organization.invitations.emailLabel": { "issue": "objectui#3546" }, + "organization.invitations.empty": { "issue": "objectui#3546" }, + "organization.invitations.expiresAt": { "issue": "objectui#3546" }, + "organization.invitations.inviteDescription": { "issue": "objectui#3546" }, + "organization.invitations.inviteTitle": { "issue": "objectui#3546" }, + "organization.invitations.invitedAs": { "issue": "objectui#3546" }, + "organization.invitations.linkCopied": { "issue": "objectui#3546" }, + "organization.invitations.linkLabel": { "issue": "objectui#3546" }, + "organization.invitations.placementDescription": { "issue": "objectui#3546" }, + "organization.invitations.placementLabel": { "issue": "objectui#3546" }, + "organization.invitations.positionsLabel": { "issue": "objectui#3546" }, + "organization.invitations.roleLabel": { "issue": "objectui#3546" }, + "organization.invitations.sendInvite": { "issue": "objectui#3546" }, + "organization.invitations.sentDescription": { "issue": "objectui#3546" }, + "organization.invitations.sentTitle": { "issue": "objectui#3546" }, + "organization.invitations.title": { "issue": "objectui#3546" }, + "organization.members.inviteMember": { "issue": "objectui#3546" }, + "organization.members.memberRemoved": { "issue": "objectui#3546" }, + "organization.members.removeConfirmAction": { "issue": "objectui#3546" }, + "organization.members.removeConfirmDescription": { "issue": "objectui#3546" }, + "organization.members.removeConfirmTitle": { "issue": "objectui#3546" }, + "organization.members.removeFailed": { "issue": "objectui#3546" }, + "organization.members.removeMember": { "issue": "objectui#3546" }, + "organization.members.roleUpdateFailed": { "issue": "objectui#3546" }, + "organization.members.roleUpdated": { "issue": "objectui#3546" }, + "organization.members.title": { "issue": "objectui#3546" }, + "organization.notFound": { "issue": "objectui#3546" }, + "organization.notFoundDescription": { "issue": "objectui#3546" }, + "organization.settings.dangerZone": { "issue": "objectui#3546" }, + "organization.settings.deleteAction": { "issue": "objectui#3546" }, + "organization.settings.deleteConfirmAction": { "issue": "objectui#3546" }, + "organization.settings.deleteConfirmDescription": { "issue": "objectui#3546" }, + "organization.settings.deleteConfirmSlugLabel": { "issue": "objectui#3546" }, + "organization.settings.deleteConfirmTitle": { "issue": "objectui#3546" }, + "organization.settings.deleteDescription": { "issue": "objectui#3546" }, + "organization.settings.deleteFailed": { "issue": "objectui#3546" }, + "organization.settings.deleteTitle": { "issue": "objectui#3546" }, + "organization.settings.deleted": { "issue": "objectui#3546" }, + "organization.settings.generalDescription": { "issue": "objectui#3546" }, + "organization.settings.generalTitle": { "issue": "objectui#3546" }, + "organization.settings.leaveAction": { "issue": "objectui#3546" }, + "organization.settings.leaveConfirmAction": { "issue": "objectui#3546" }, + "organization.settings.leaveConfirmDescription": { "issue": "objectui#3546" }, + "organization.settings.leaveConfirmTitle": { "issue": "objectui#3546" }, + "organization.settings.leaveDescription": { "issue": "objectui#3546" }, + "organization.settings.leaveFailed": { "issue": "objectui#3546" }, + "organization.settings.leaveTitle": { "issue": "objectui#3546" }, + "organization.settings.leftOrg": { "issue": "objectui#3546" }, + "organization.settings.logoClear": { "issue": "objectui#3546" }, + "organization.settings.logoLabel": { "issue": "objectui#3546" }, + "organization.settings.logoReplace": { "issue": "objectui#3546" }, + "organization.settings.logoUpload": { "issue": "objectui#3546" }, + "organization.settings.logoUploadFailed": { "issue": "objectui#3546" }, + "organization.settings.logoUploaded": { "issue": "objectui#3546" }, + "organization.settings.nameLabel": { "issue": "objectui#3546" }, + "organization.settings.readOnlyNote": { "issue": "objectui#3546" }, + "organization.settings.save": { "issue": "objectui#3546" }, + "organization.settings.saveFailed": { "issue": "objectui#3546" }, + "organization.settings.saved": { "issue": "objectui#3546" }, + "organization.settings.slugLabel": { "issue": "objectui#3546" }, + "organization.switcher.groupHint": { "issue": "objectui#3546" }, + "organization.switcher.groupLabel": { "issue": "objectui#3546" }, + "organization.switcher.label": { "issue": "objectui#3546" }, + "organization.switcher.manageMembers": { "issue": "objectui#3546" }, + "organization.tabs.invitations": { "issue": "objectui#3546" }, + "organization.tabs.members": { "issue": "objectui#3546" }, + "organization.tabs.settings": { "issue": "objectui#3546" }, + "perm.facet.adminScope": { "issue": "objectui#3546" }, + "perm.facet.designInStudio": { "issue": "objectui#3546" }, + "perm.facet.designInStudioHint": { "issue": "objectui#3546" }, + "perm.facet.fields": { "issue": "objectui#3546" }, + "perm.facet.more": { "issue": "objectui#3546" }, + "perm.facet.none": { "issue": "objectui#3546" }, + "perm.facet.objects": { "issue": "objectui#3546" }, + "perm.facet.rls": { "issue": "objectui#3546" }, + "perm.facet.tabs": { "issue": "objectui#3546" }, + "preview.history.applyLabel": { "issue": "objectui#3546" }, + "preview.history.button": { "issue": "objectui#3546" }, + "preview.history.description": { "issue": "objectui#3546" }, + "preview.history.empty": { "issue": "objectui#3546" }, + "preview.history.items": { "issue": "objectui#3546" }, + "preview.history.loadFailed": { "issue": "objectui#3546" }, + "preview.history.loading": { "issue": "objectui#3546" }, + "preview.history.revert": { "issue": "objectui#3546" }, + "preview.history.revertAction": { "issue": "objectui#3546" }, + "preview.history.revertFailed": { "issue": "objectui#3546" }, + "preview.history.revertLabel": { "issue": "objectui#3546" }, + "preview.history.reverted": { "issue": "objectui#3546" }, + "preview.history.title": { "issue": "objectui#3546" }, + "preview.unpublishedBar.message": { "issue": "objectui#3546" }, + "preview.unpublishedBar.publish": { "issue": "objectui#3546" }, + "preview.unpublishedBar.publishFailed": { "issue": "objectui#3546" }, + "preview.unpublishedBar.published": { "issue": "objectui#3546" }, + "preview.unpublishedBar.publishing": { "issue": "objectui#3546" }, + "wizard.missingRequired": { "issue": "objectui#3546" }, + "workspace.multiOrgDisabled": { "issue": "objectui#3546" } + }, + + "//": "Template keys whose static head matches no en key at all, so every expansion misses.", + "missingPrefixes": { + "console.ai.group.": { "issue": "objectui#3546" }, + "gantt.linkEnd.": { "issue": "objectui#3546" }, + "marketplace.disclosure.runtime.": { "issue": "objectui#3546" }, + "organization.invitations.status.": { "issue": "objectui#3546" } + } +}