From 1f0686b5ea0d213ef454b80e647cecb80ec85583 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:55:51 +0000 Subject: [PATCH 1/2] feat(scripts): check template-literal t() key families member by member, not just by prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `missing-prefix` is the only claim about a dynamic key that is true without knowing the substitution, and its own header says so. The cost is the complement: a head that resolves and a MEMBER that does not is invisible to every gate in the repo. Pack-vs-pack parity reads ten packs missing it identically as full parity; en-drift fires on a value CHANGING, which a key that was never added never does. `filterBuilder.operators` was the measured instance — six of twenty-two operators missing from all ten packs while the head resolved sixteen members deep, and users read the raw key. A member set is a decision, not a measurement: this file parses source with no type checker, so it cannot follow `job.status` to its declaration. It CAN read a declaration a human names. So every pack-backed template family is declared in DYNAMIC_KEY_FAMILIES as one of two things — a `vocabulary` naming a readable declaration (union, const array, object table, Set, interface field), or `enumerable: false` with one of four reasons it has no static member set. Measured on this checkout: 25 families, not the card's estimated ~20; 18 with a static vocabulary (112 member keys now checked exactly), 7 without. Plus 35 dynamic call sites with no static head at all, which neither rule can reach. Coverage grows in one direction only. `missing-prefix` still runs for every dynamic head, declared or not, and a test pins that declaring a family does not buy it out of the prefix rule. Three ratchet rules keep the registry honest — an undeclared family fails, a stale entry fails, and a vocabulary resolving to zero members fails, because vacuous and passing read identically. The first run found two genuinely missing members, `home.recentApps.itemType`'s `report` and `metadata`, both written at runtime by useTrackRouteAsRecent. Baselined against objectui#6023 rather than invented into ten packs. --- .../check-i18n-call-site-keys.test.ts | 335 ++++++++- scripts/check-i18n-call-site-keys.mjs | 637 +++++++++++++++++- scripts/i18n-call-site-key-baseline.json | 29 +- 3 files changed, 955 insertions(+), 46 deletions(-) diff --git a/scripts/__tests__/check-i18n-call-site-keys.test.ts b/scripts/__tests__/check-i18n-call-site-keys.test.ts index 2775112edf..0b65d23f7f 100644 --- a/scripts/__tests__/check-i18n-call-site-keys.test.ts +++ b/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -10,9 +10,11 @@ import { collectEnKeys, collectSourceFiles, EXCLUDED_TRANSLATORS, + DYNAMIC_KEY_FAMILIES, EXTERNALLY_INTERPOLATED_HOLES, holesOf, PACK_HOOK, + readVocabulary, RESERVED_OPTION_NAMES, } from '../check-i18n-call-site-keys.mjs'; @@ -149,9 +151,17 @@ const EN_FIXTURE = `const en = { export default en; `; +/** + * The registry's own element and vocabulary-spec types, taken from the module + * rather than restated here — a restated shape is a second declaration free to + * drift from the one the gate actually enforces. + */ +type Family = (typeof DYNAMIC_KEY_FAMILIES)[number]; +type Spec = Parameters[1]; + /** Findings of `reason` produced for a synthetic repo, as `key@file:line`. */ -function findingsOf(root: string, reason: string): string[] { - return analyze(root) +function findingsOf(root: string, reason: string, families: Family[] = []): string[] { + return analyze(root, { families }) .findings.filter((f: { reason: string }) => f.reason === reason) .map((f: { detail: string; file: string; line: number }) => `${f.detail}@${f.file}:${f.line}`) .sort(); @@ -287,7 +297,11 @@ describe('dynamic keys: counted, never failed — except when the whole family i export const A = (c: string) => { const { t } = useObjectTranslation(); return t(\`grid.column.\${c}\`); }; `, }); - const { findings, counters } = analyze(root); + // `families: []` — this case is about the PREFIX rule, so the registry is + // emptied rather than left pointing at the real repo's 25 heads, none of + // which exist in a synthetic root. The registry's own rules get their own + // describe below. + const { findings, counters } = analyze(root, { families: [{ head: 'grid.column.', enumerable: false, why: 'runtime-data', reason: 'fixture' }] }); expect(findings).toEqual([]); expect(counters.dynamicKeySites).toBe(1); }); @@ -302,6 +316,22 @@ export const A = (c: string) => { const { t } = useObjectTranslation(); return t expect(findingsOf(root, 'missing-prefix')).toEqual(['gantt.linkEnd.@packages/x/src/A.tsx:2']); }); + it('the prefix rule still fires on a head the registry DECLARES — the two rules stack, they do not replace each other', () => { + // objectui#4964's guard against the failure this lane keeps hitting: a + // stricter-looking gate that silently covers less. Declaring a family must + // never buy it out of `missing-prefix`. + 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(\`nowhere.\${c}\`); }; +`, + 'packages/x/src/vocab.ts': `export type Nowhere = 'a' | 'b';\n`, + }); + const families: Family[] = [{ head: 'nowhere.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'Nowhere', kind: 'union' } }]; + const reasons = analyze(root, { families }).findings.map((f: { reason: string }) => f.reason).sort(); + expect(reasons).toEqual(['missing-member', 'missing-member', 'missing-prefix']); + }); + 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, @@ -309,9 +339,12 @@ export const A = (c: string) => { const { t } = useObjectTranslation(); return t export const A = (k: string) => { const { t } = useObjectTranslation(); return t(k); }; `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.dynamicKeySites).toBe(1); + // No static head at all, so it is not a family either — neither rule can + // reach it, and the counter is the only trace it leaves. + expect(counters.headlessDynamicKeySites).toBe(1); }); }); @@ -334,7 +367,7 @@ export const A = () => { 'packages/i18n/src/locales/en.ts': EN_FIXTURE, 'packages/anything/src/Probe.tsx': probeFile, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.probeSites).toBe(1); }); @@ -361,7 +394,7 @@ 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); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.skippedLocalTable).toBeGreaterThanOrEqual(2); expect(counters.packCallSites).toBe(0); @@ -385,7 +418,7 @@ export const Page = () => t('engine.directory.title'); export const Page = () => t('engine.directory.title'); `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.skippedLocalTable).toBeGreaterThanOrEqual(1); }); @@ -427,7 +460,7 @@ export const A = () => { const { t } = copy(); return t('common.save'); }; }; `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.skippedNotATranslator).toBe(1); }); @@ -464,7 +497,7 @@ export function describe(t: TranslateFn): string { return t('legacy.helper'); } describe('an inline defaultValue on a key that EXISTS must match the en value (objectui#3810)', () => { /** Findings of `reason`, rendered as `key: expected -> actual`. */ function driftOf(root: string): string[] { - return analyze(root) + return analyze(root, { families: [] }) .findings.filter((f: { reason: string }) => f.reason === 'default-value-drift') .map((f: { detail: string; expected: string; actual: string }) => `${f.detail}: ${f.expected} -> ${f.actual}`) .sort(); @@ -477,7 +510,7 @@ describe('an inline defaultValue on a key that EXISTS must match the en value (o export const A = () => { const { t } = useObjectTranslation(); return t('common.save', { defaultValue: 'Save' }); }; `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.matchingDefaultValues).toBe(1); }); @@ -530,7 +563,7 @@ export const A = () => { export const A = () => { const { t } = useObjectTranslation(); return t('common.reset', { defaultValue: 'Reset' }); }; `, }); - expect(analyze(root).findings.map((f: { reason: string }) => f.reason)).toEqual(['missing-key']); + expect(analyze(root, { families: [] }).findings.map((f: { reason: string }) => f.reason)).toEqual(['missing-key']); }); it('counts rather than judges a computed default — there is no text to compare', () => { @@ -543,7 +576,7 @@ export const A = (label: string) => { }; `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.computedDefaultValues).toBe(2); expect(counters.literalDefaultValues).toBe(0); @@ -565,7 +598,7 @@ export const A = (flag: boolean) => { }; `, }); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.unjudgedDefaultValues).toBe(3); }); @@ -597,7 +630,7 @@ export const A = (flag: boolean) => { describe('what a call site passes must be what the en value has holes for (objectui#3845)', () => { /** Parity findings as `key: inert=[…] unfilled=[…]` — both directions visible. */ function parityOf(root: string): string[] { - return analyze(root) + return analyze(root, { families: [] }) .findings.filter((f: { reason: string }) => f.reason === 'interpolation-parity') .map( (f: { detail: string; inert: string[]; unfilled: string[] }) => @@ -621,7 +654,7 @@ export const A = (name: string, n: number, idx: number) => { it('is silent when the argument set is exactly the hole set', () => { const root = repoWith(callSite("[t('interp.greet', { name }), t('interp.both', { name, n }), t('interp.bare')]")); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); // Shorthand properties (`{ name }`) are names too — the AST form differs from // `{ name: name }` and reading only one of them would make the rule blind to @@ -663,7 +696,7 @@ export const A = (name: string, n: number, idx: number) => { // 2 hits where there was 1. This is that exact shape, and both calls are // correct — so the whole thing must be silent. const root = repoWith(callSite("t('interp.enlarge', { name: name || t('interp.imageAlt', { index: idx + 1 }) })")); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); expect(findings).toEqual([]); expect(counters.judgedInterpolation).toBe(2); }); @@ -703,7 +736,7 @@ export const A = (name: string, n: number, idx: number) => { it('never judges a plural family, where there is no single value to read holes off', () => { const root = repoWith(callSite("t('detail.showEmptyRelated', { count: n, thing: name })")); - const { findings, counters } = analyze(root); + const { findings, counters } = analyze(root, { families: [] }); // `detail.showEmptyRelated` resolves through `_one`/`_other`; picking one // form's holes as the answer would be an invention, so `thing` goes // unreported rather than being called inert on a guess. @@ -726,7 +759,7 @@ export const A = (rest: Record, key: string, opts: Record, key: string, opts: Record f.reason)).toEqual(['missing-key']); + expect(analyze(root, { families: [] }).findings.map((f: { reason: string }) => f.reason)).toEqual(['missing-key']); }); it('reads a formatter, an unescape marker and a keypath as the option they name', () => { @@ -849,7 +882,7 @@ export const A = () => { const { t } = useObjectTranslation(); return t('auth.fo describe('a literal fallback beside the call is dead on every path (objectui#4117)', () => { /** Sibling findings as `key@line: ` — position and text both visible. */ function siblingsOf(root: string): string[] { - return analyze(root) + return analyze(root, { families: [] }) .findings.filter((f: { reason: string }) => f.reason === 'dead-sibling-fallback') .map((f: { detail: string; line: number; operator: string; actual: string }) => `${f.detail}@${f.line}: ${f.operator} ${f.actual}`, @@ -923,7 +956,7 @@ export const A = (name: string, label: string, n: number) => { // fallback, which is the healthy arrangement and nothing to report. const root = repoWith(callSite("[label || t('common.save'), name ?? t('common.cancel')]")); expect(siblingsOf(root)).toEqual([]); - expect(analyze(root).counters.siblingFallbacks).toBe(0); + expect(analyze(root, { families: [] }).counters.siblingFallbacks).toBe(0); }); it('counts rather than judges a non-literal fallback — there is no copy to delete', () => { @@ -932,7 +965,7 @@ export const A = (name: string, label: string, n: number) => { // computed `defaultValue` in objectui#3810. const root = repoWith(callSite("t('common.save') || label")); expect(siblingsOf(root)).toEqual([]); - expect(analyze(root).counters.computedSiblingFallbacks).toBe(1); + expect(analyze(root, { families: [] }).counters.computedSiblingFallbacks).toBe(1); }); it('counts rather than judges an OPTIONAL call, where the fallback is live', () => { @@ -948,7 +981,7 @@ export const A = (name: string, label: string, n: number) => { `, }); expect(siblingsOf(root)).toEqual([]); - const { counters } = analyze(root); + const { counters } = analyze(root, { families: [] }); expect(counters.optionalCallFallbacks).toBe(1); // And the file was SCANNED at all: its only spelling is `t?.(`, which the // pre-filter used to drop — silently, out of all five classes at once. @@ -960,7 +993,7 @@ export const A = (name: string, label: string, n: number) => { // leaf today; the abstention is what stops the first one being a wrong red. const root = repoWith(callSite("t('edge.blank') || 'Something'")); expect(siblingsOf(root)).toEqual([]); - expect(analyze(root).counters.unjudgedSiblingFallbacks).toBe(1); + expect(analyze(root, { families: [] }).counters.unjudgedSiblingFallbacks).toBe(1); }); it('counts rather than judges a plural family and a dynamic key', () => { @@ -968,7 +1001,7 @@ export const A = (name: string, label: string, n: number) => { // to read, and a dynamic key denotes no one key at all. const root = repoWith(callSite("[t('detail.showEmptyRelated', { count: n }) || 'more', t(`grid.column.${name}`) || 'Label']")); expect(siblingsOf(root)).toEqual([]); - const { counters } = analyze(root); + const { counters } = analyze(root, { families: [] }); expect(counters.siblingFallbacks).toBe(2); expect(counters.unjudgedSiblingFallbacks).toBe(2); }); @@ -978,7 +1011,7 @@ export const A = (name: string, label: string, n: number) => { // claim this rule can make about it — and objectui#3546 spent months in // exactly that transition, where the fallback is the only English there is. const root = repoWith(callSite("t('nowhere.key') || 'English'")); - expect(analyze(root).findings.map((f: { reason: string }) => f.reason)).toEqual(['missing-key']); + expect(analyze(root, { families: [] }).findings.map((f: { reason: string }) => f.reason)).toEqual(['missing-key']); }); it('main carries no literal fallback beside a call, which is why this rule has no baseline', () => { @@ -1037,6 +1070,256 @@ export const A = (name: string, label: string, n: number) => { }); }); +describe('a declared dynamic family is checked MEMBER by member (objectui#4964)', () => { + /** + * The class the prefix rule structurally cannot reach: the head resolves, so + * `missing-prefix` is satisfied, and one member of the vocabulary the call + * site iterates has no leaf in `en`. Ten packs missing it identically is full + * parity, so no pack gate sees it either. + * + * Every case below carries a NON-VACUITY half: a family whose members are all + * present must produce no finding *while the checker is demonstrably reading + * them*, because "found nothing" and "checked nothing" are the same output. + * `counters.checkedMembers` is what tells them apart, and it is asserted on + * every green case rather than only on the reds. + */ + const enWithFamily = `const en = { + common: { save: 'Save' }, + mode: { day: 'Day', week: 'Week' }, + badge: { alpha: { short: 'A', title: 'Alpha' }, beta: { short: 'B', title: 'Beta' } }, +} as const; +export default en; +`; + const callSite = (template: string) => `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (x: string) => { const { t } = useObjectTranslation(); return t(\`${template}\`); }; +`; + + it('reports the member `en` lacks, and only that one', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': enWithFamily, + 'packages/x/src/A.tsx': callSite('mode.${x}'), + 'packages/x/src/vocab.ts': `export type Mode = 'day' | 'week' | 'month';\n`, + }); + const families: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'Mode', kind: 'union' } }]; + const { findings, counters } = analyze(root, { families }); + expect(findings.map((f: { reason: string; detail: string }) => `${f.reason}:${f.detail}`)).toEqual([ + 'missing-member:mode.month', + ]); + expect(counters.checkedMembers).toBe(3); + }); + + it('is silent when every member resolves — and proves it looked', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': enWithFamily, + 'packages/x/src/A.tsx': callSite('mode.${x}'), + 'packages/x/src/vocab.ts': `export type Mode = 'day' | 'week';\n`, + }); + const families: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'Mode', kind: 'union' } }]; + const { findings, counters } = analyze(root, { families }); + expect(findings).toEqual([]); + // The non-vacuity half. Without this, a reader that silently returned no + // members would produce exactly the same empty finding list. + expect(counters.checkedMembers).toBe(2); + expect(counters.enumerableFamilies).toBe(1); + }); + + it('expands the template TAIL, so a member is checked as the leaf the call site renders', () => { + // `t(`badge.${k}.short`)` asks for `badge.alpha.short`, not `badge.alpha` — + // which is a BRANCH, and a branch resolves for the wrong reason. + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': enWithFamily, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (k: string) => { + const { t } = useObjectTranslation(); + return [t(\`badge.\${k}.short\`), t(\`badge.\${k}.body\`)]; +}; +`, + 'packages/x/src/vocab.ts': `export type Badge = 'alpha' | 'beta';\n`, + }); + const families: Family[] = [{ head: 'badge.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'Badge', kind: 'union' } }]; + const { findings, counters } = analyze(root, { families }); + expect(findings.map((f: { detail: string }) => f.detail).sort()).toEqual(['badge.alpha.body', 'badge.beta.body']); + // Two tails x two members: the `.short` pair resolves, the `.body` pair does not. + expect(counters.checkedMembers).toBe(4); + }); + + it('declines to expand a MULTI-substitution template, and counts it instead of guessing', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': enWithFamily, + 'packages/x/src/A.tsx': `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (a: string, b: string) => { const { t } = useObjectTranslation(); return t(\`mode.\${a}.\${b}\`); }; +`, + 'packages/x/src/vocab.ts': `export type Mode = 'day' | 'week';\n`, + }); + const families: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'Mode', kind: 'union' } }]; + const { findings, counters } = analyze(root, { families }); + expect(findings).toEqual([]); + expect(counters.unexpandableFamilySites).toBe(1); + expect(counters.checkedMembers).toBe(0); + }); + + it('a family declared `enumerable: false` keeps its prefix check and gains nothing else', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': enWithFamily, + 'packages/x/src/A.tsx': callSite('mode.${x}'), + }); + const families: Family[] = [{ head: 'mode.', enumerable: false, why: 'runtime-data', reason: 'server-supplied' }]; + const { findings, counters } = analyze(root, { families }); + expect(findings).toEqual([]); + expect(counters.notEnumerableFamilies).toBe(1); + expect(counters.checkedMembers).toBe(0); + }); +}); + +describe('the family registry is a ratchet in both directions (objectui#4964)', () => { + const EN_MODE = `const en = { mode: { day: 'Day' } } as const;\nexport default en;\n`; + const CALL = `import { useObjectTranslation } from '${I18N_PKG}'; +export const A = (x: string) => { const { t } = useObjectTranslation(); return t(\`mode.\${x}\`); }; +`; + + it('an UNDECLARED family fails — a new template family cannot land unguarded', () => { + const root = repoWith({ 'packages/i18n/src/locales/en.ts': EN_MODE, 'packages/x/src/A.tsx': CALL }); + expect(findingsOf(root, 'undeclared-dynamic-family')).toEqual(['mode.@packages/x/src/A.tsx:2']); + }); + + it('a STALE entry fails too, so the registry can only describe families that exist', () => { + const root = repoWith({ 'packages/i18n/src/locales/en.ts': EN_MODE }); + const families: Family[] = [{ head: 'gone.', enumerable: false, why: 'runtime-data', reason: 'x' }]; + const reasons = analyze(root, { families }).findings.map((f: { reason: string }) => f.reason); + expect(reasons).toEqual(['stale-dynamic-family']); + }); + + it('two entries for one head fail rather than letting the second sit dead', () => { + const root = repoWith({ 'packages/i18n/src/locales/en.ts': EN_MODE, 'packages/x/src/A.tsx': CALL }); + const families: Family[] = [ + { head: 'mode.', enumerable: false, why: 'runtime-data', reason: 'first' }, + { head: 'mode.', enumerable: false, why: 'runtime-data', reason: 'second' }, + ]; + expect(analyze(root, { families }).findings.map((f: { reason: string }) => f.reason)).toEqual(['duplicate-family']); + }); + + it('a vocabulary that resolves to NOTHING fails — vacuous and passing read identically', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_MODE, + 'packages/x/src/A.tsx': CALL, + 'packages/x/src/vocab.ts': `export const MODES: string[] = [];\n`, + }); + const families: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'MODES', kind: 'array' } }]; + expect(findingsOf(root, 'empty-vocabulary', families)).toEqual(['mode.@packages/x/src/A.tsx:2']); + }); + + it('a vocabulary that moved, was renamed, or changed shape fails instead of degrading to zero members', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': EN_MODE, + 'packages/x/src/A.tsx': CALL, + 'packages/x/src/vocab.ts': `export const MODES = buildModes();\n`, + }); + // A module that is not there at all, and a declaration whose initializer is + // a call rather than a literal — both are "cannot read", never "read as none". + const missing: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/nope.ts', name: 'MODES', kind: 'array' } }]; + expect(analyze(root, { families: missing }).findings.map((f: { reason: string }) => f.reason)).toEqual([ + 'unreadable-vocabulary', + ]); + const derived: Family[] = [{ head: 'mode.', vocabulary: { module: 'packages/x/src/vocab.ts', name: 'MODES', kind: 'array' } }]; + expect(analyze(root, { families: derived }).findings.map((f: { reason: string }) => f.reason)).toEqual([ + 'unreadable-vocabulary', + ]); + }); +}); + +describe('readVocabulary reads each declared shape, and refuses what it cannot read', () => { + const shapes: Array<[string, string, Omit, string[]]> = [ + ['union', "export type X = 'a' | 'b';", { kind: 'union', name: 'X' }, ['a', 'b']], + ['single-member union', "export type X = 'a';", { kind: 'union', name: 'X' }, ['a']], + ['array', "export const X = ['a', 'b'] as const;", { kind: 'array', name: 'X' }, ['a', 'b']], + ['set', "export const X = new Set(['a', 'b']);", { kind: 'set', name: 'X' }, ['a', 'b']], + ['objectKeys', "export const X = { a: 1, 'b': 2 };", { kind: 'objectKeys', name: 'X' }, ['a', 'b']], + ['arrayField', "export const X = [{ value: 'a' }, { value: 'b' }];", { kind: 'arrayField', name: 'X', field: 'value' }, ['a', 'b']], + ['objectField', "export const X = { one: { k: 'a' }, two: { k: 'b' } };", { kind: 'objectField', name: 'X', field: 'k' }, ['a', 'b']], + ['interfaceField', "export interface X { f: 'a' | 'b'; g: string }", { kind: 'interfaceField', name: 'X', field: 'f' }, ['a', 'b']], + ['type-literal field', "export type X = { f: 'a' | 'b' };", { kind: 'interfaceField', name: 'X', field: 'f' }, ['a', 'b']], + ]; + for (const [label, source, spec, expected] of shapes) { + it(`reads a ${label}`, () => { + const root = repoWith({ 'packages/x/src/v.ts': `${source}\n` }); + expect(readVocabulary(root, { module: 'packages/x/src/v.ts', ...spec })).toEqual(expected); + }); + } + + const refusals: Array<[string, string, Omit]> = [ + ['a union with a non-literal arm', 'export type X = "a" | number;', { kind: 'union', name: 'X' }], + ['an array holding a non-literal', 'export const X = ["a", other];', { kind: 'array', name: 'X' }], + ['an object built by spread', 'export const X = { ...base, a: 1 };', { kind: 'objectKeys', name: 'X' }], + ['a name that is not declared here', 'export const Y = ["a"];', { kind: 'array', name: 'X' }], + ['an interface field that is not a literal union', 'export interface X { f: string }', { kind: 'interfaceField', name: 'X', field: 'f' }], + ]; + for (const [label, source, spec] of refusals) { + it(`refuses ${label} rather than reading it as empty`, () => { + const root = repoWith({ 'packages/x/src/v.ts': `${source}\n` }); + expect(readVocabulary(root, { module: 'packages/x/src/v.ts', ...spec })).toBeNull(); + }); + } +}); + +describe('the checked-in registry describes this repo (objectui#4964)', () => { + it('every family declares exactly one of a vocabulary or a reason it has none', () => { + const WHY = new Set(['runtime-data', 'external-vocabulary', 'unnamed-union', 'open-forwarder']); + for (const family of DYNAMIC_KEY_FAMILIES) { + expect(family.head, 'a head must end at a member boundary').toMatch(/\.$/); + if (family.enumerable === false) { + expect(WHY, `${family.head}: unknown \`why\``).toContain(family.why); + expect(family.reason!.length, `${family.head}: a reason must actually say something`).toBeGreaterThan(40); + expect(family.vocabulary).toBeUndefined(); + } else { + expect(family.vocabulary, `${family.head}: neither a vocabulary nor \`enumerable: false\``).toBeTruthy(); + } + } + }); + + it('every declared vocabulary resolves to a NON-EMPTY member set on this checkout', () => { + // The registry-wide non-vacuity assertion. A vocabulary that stopped + // resolving would leave the gate green while checking less, which is the + // one regression this class could introduce. + for (const family of DYNAMIC_KEY_FAMILIES) { + if (family.enumerable === false) continue; + const spec = family.vocabulary!; + const members = readVocabulary(repoRoot, spec); + expect(members, `${family.head}: ${spec.name} is unreadable`).not.toBeNull(); + expect((members as string[]).length, `${family.head}: resolved to zero members`).toBeGreaterThan(0); + } + }); + + it('the split is what the report says it is, and the check is not vacuous on `main`', () => { + const { counters, findings } = analyze(repoRoot); + expect(counters.declaredFamilies).toBe(DYNAMIC_KEY_FAMILIES.length); + expect(counters.enumerableFamilies + counters.notEnumerableFamilies).toBe(counters.declaredFamilies); + // Measured on `main`: 18 of 25 families are exactly checkable. The number is + // pinned low rather than exactly so paying off a `unnamed-union` or + // `external-vocabulary` entry raises coverage without failing this test — + // but LOSING coverage does fail it. + expect(counters.enumerableFamilies).toBeGreaterThanOrEqual(18); + expect(counters.checkedMembers).toBeGreaterThanOrEqual(112); + // Neither ratchet direction may be firing on a clean checkout. + const ratchet = findings.filter((f: { reason: string }) => + ['undeclared-dynamic-family', 'stale-dynamic-family', 'duplicate-family', 'empty-vocabulary', 'unreadable-vocabulary'].includes( + f.reason, + ), + ); + expect(ratchet, 'the registry no longer describes the repo').toEqual([]); + }); + + it('finds a known-PRESENT member — the positive control for every "not found" above', () => { + // Proves the expansion reaches real `en` leaves. `gantt.viewMode.day` is + // defined; if the checker could not see it, every green family above would + // be green for the wrong reason. + const { leaves } = collectEnKeys(repoRoot); + expect(leaves.has('gantt.viewMode.day'), 'the fixture key this control rests on has moved').toBe(true); + const viewMode = DYNAMIC_KEY_FAMILIES.find((f) => f.head === 'gantt.viewMode.'); + expect(readVocabulary(repoRoot, viewMode!.vocabulary!)).toContain('day'); + expect(analyze(repoRoot).findings.filter((f: { detail: string }) => f.detail === 'gantt.viewMode.day')).toEqual([]); + }); +}); + describe('the baseline is a ratchet', () => { const finding = (reason: string, detail: string) => ({ reason, detail, file: 'f.tsx', line: 1, column: 1 }); diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs index 425afa6851..1e57f51a57 100644 --- a/scripts/check-i18n-call-site-keys.mjs +++ b/scripts/check-i18n-call-site-keys.mjs @@ -265,13 +265,43 @@ * for a ratchet to hold. And a fallback written on a key `en` does NOT define * stays legal — that is class 1's business, not this one's. * + * 6. `missing-member` (objectui#4964) — the template family's HEAD resolves, so + * class 2 is satisfied, and a specific MEMBER of the vocabulary the call site + * iterates has no leaf in `en`. This is the sixth blind spot and it was blind + * to all five, plus to both other i18n gates, by construction: class 2 says in + * its own words that the prefix is "the only claim about a dynamic key that is + * true without knowing the value", parity compares ten packs that are missing + * it identically (full parity, green), and en-drift fires on a value CHANGING, + * which a key that was never added never does. `filterBuilder.operators` was + * the measured instance: six of its twenty-two operators were missing from all + * ten packs while the head resolved sixteen members deep, and users read + * `filterBuilder.operators.isNull` in the operator dropdown (fixed in + * objectui#4962, which pinned that ONE family; this class is the generalisation). + * + * Knowing the members is a DECISION, not a measurement, so it is declared + * rather than inferred — see DYNAMIC_KEY_FAMILIES below for the registry, the + * six vocabulary shapes it can read, and the four reasons a family may declare + * that it has no static member set. Three ratchet rules keep the registry + * honest in both directions (`undeclared-dynamic-family`, + * `stale-dynamic-family`) and stop it going vacuous + * (`empty-vocabulary`/`unreadable-vocabulary` — a vocabulary that resolves to + * nothing reads exactly like one that passes). + * + * Baselined like classes 1 and 2, in `missingMembers`, and for the same + * reason: the first full run found real debt (2 members over 25 families and + * 112 checked member keys), and a missing translation is a gap to surface, not + * something to invent pack entries for. + * * ## 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 + * that can be decided, and class 6 recovers the part of THAT which a declared + * vocabulary can settle; what neither reaches is a dynamic key with no static + * head at all (`t(key)` on a variable — 35 sites), which nothing but a type + * checker could resolve. Same treatment, same reason, for the deliberate * `I18N_PROBE_FLAG` misses (see below) and for the skipped binding classes. * * ## The probe exclusion @@ -288,8 +318,8 @@ * ## 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 — classes 1 - * and 2 only; classes 3 and 4 have no baseline and never needed one. It is a + * `main` when this gate landed, each with the issue tracking its fix — classes 1, + * 2 and 6 only; classes 3, 4 and 5 have no baseline and never needed one. 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 @@ -448,6 +478,367 @@ export const EXCLUDED_TRANSLATORS = [ }, ]; +// ── dynamic key families: the vocabulary registry (objectui#4964) ───────────── + +/** + * Every pack-backed dynamic key family in the repo, each declaring how its + * member set is known — or declaring, with a reason, that it cannot be known. + * + * ## Why a registry rather than inference + * + * `missing-prefix` (class 2) is the only claim about a template key that is + * true without knowing the substitution: if the static HEAD matches nothing, + * every expansion misses. Its own header says so. The cost is that the + * complement — a head that resolves and a MEMBER that does not — is invisible + * to the whole gate farm, because the other two i18n gates are pack-vs-pack: + * ten packs identically missing `filterBuilder.operators.isNull` is full + * parity, and full parity is green. Users read the raw key + * (objectui#4964; the measured instance was fixed in objectui#4962). + * + * Closing that needs the member set, and a member set is a decision, not a + * measurement: `t(`grid.import.jobStatus.${job.status}`)` is exactly checkable + * only because `ImportJobStatus` is a union of five string literals somewhere, + * while `t(`approvalsInbox.${key}`)` forwards a `key: string` parameter and has + * no member set at all. This file parses source without a type checker (see + * `collectEnKeys`'s header — no build step, no TS loader), so it cannot follow + * `job.status` to its declaration. What it CAN do is read a declaration a human + * NAMES, from the module that holds it. + * + * So each family below is one of two things, and the split is the point: + * + * - `vocabulary` — the member set is a named declaration this file reads from + * source. Every `head + member + tail` is then checked as an ordinary key, + * and a member missing from `en` is a `missing-member` finding. + * - `enumerable: false` — there is no static member set. The family keeps its + * prefix check and NOTHING ELSE, and the `reason` says why. Pretending + * otherwise would produce either false reds (guessing a vocabulary) or a + * check that silently skips the family (the failure mode this card names). + * + * ## Both ratchet directions + * + * A head observed in the scan with no entry here is `undeclared-dynamic-family` + * and fails: a new template family cannot land unguarded, which is what let the + * twenty-odd families below accumulate unmeasured. An entry whose head no + * longer appears is `stale-dynamic-family` and fails too, so this list can only + * describe families that exist. And a `vocabulary` that resolves to ZERO + * members is `empty-vocabulary` — a vacuous exact check reads identical to a + * passing one, and that is the one way this class could quietly cover less than + * the prefix check it sits on top of. The prefix check itself is untouched: it + * still runs for every dynamic head, declared or not. + * + * `kind` tells the reader what shape the declaration is: + * + * `union` `type X = 'a' | 'b'` -> the literals + * `array` `const X = ['a', 'b'] as const` -> the elements + * `arrayField` `const X = [{ value: 'a' }, …]` + field -> that field's values + * `objectKeys` `const X = { a: …, b: … }` -> the property names + * `objectField` `const X = { a: { k: 'x' } }` + field -> that field's values + * `set` `const X = new Set(['a', 'b'])` -> the elements + * `interfaceField` `interface X { f: 'a' | 'b' }` + field -> that property's literals + * + * And `enumerable: false` carries a `why`, because the four reasons are not the + * same finding and only one of them is permanent: + * + * `runtime-data` the substitution is server- or user-supplied. There is + * no member set to know, at any point, by anyone. + * `external-vocabulary` the member set exists and is authoritative, but it + * lives in a dependency (`@objectstack/spec`), not in + * this repo's source. Bridgeable — by a repo-local + * exhaustive `Record` this reader can read. + * `unnamed-union` the member set is written inline (a parameter + * annotation, an anonymous state type) rather than as a + * declaration that can be named. Bridgeable by naming it. + * `open-forwarder` the call site takes `key: string` and forwards it, so + * the family is the whole namespace and the template is + * a namespace prefix, not a member position. + */ +/** + * @typedef {{ module: string, name: string, + * kind: 'union' | 'array' | 'arrayField' | 'objectKeys' | 'objectField' | 'set' | 'interfaceField', + * field?: string }} VocabularySpec + * @typedef {{ head: string, vocabulary?: VocabularySpec, enumerable?: boolean, + * why?: 'runtime-data' | 'external-vocabulary' | 'unnamed-union' | 'open-forwarder', + * reason?: string }} DynamicKeyFamily + * + * @type {DynamicKeyFamily[]} + */ +export const DYNAMIC_KEY_FAMILIES = [ + { + head: 'appDesigner.fieldDesigner.typeCategory.', + vocabulary: { module: 'packages/plugin-designer/src/FieldDesigner.tsx', name: 'FieldTypeCategory', kind: 'union' }, + }, + { + head: 'approvalsInbox.', + enumerable: false, + why: 'open-forwarder', + reason: + 'ApprovalsInboxPage and RecordApprovalsPanel both wrap the pack in ' + + '`tr(key: string, defaultValue: string)`, so the template head is the whole ' + + '`approvalsInbox` namespace (169 keys) and the substitution is every leaf under it. ' + + 'There is no member position to check; the literal keys are at the `tr()` call sites, ' + + 'which pass strings this file cannot follow through the helper.', + }, + { + head: 'capability.group.', + vocabulary: { module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', name: 'SCOPE_ORDER', kind: 'array' }, + }, + { + head: 'capability.label.', + vocabulary: { module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', name: 'CURATED_CAPABILITY_LABELS', kind: 'set' }, + }, + { + head: 'common.', + enumerable: false, + why: 'unnamed-union', + reason: + "`useChatbotLabel` annotates its parameter `key: 'openChat' | 'closeChat'` inline. The " + + 'member set is real and closed, but it is not a declaration this reader can be pointed ' + + 'at. Naming that union would make the family exactly checkable — a one-line change in ' + + 'packages/plugin-chatbot/src/FloatingChatbotTrigger.tsx, deliberately left to the owner ' + + 'rather than folded into the gate card.', + }, + { + head: 'console.ai.group.', + vocabulary: { module: 'packages/app-shell/src/console/ai/ConversationsSidebar.tsx', name: 'ConversationGroupKey', kind: 'union' }, + }, + { + head: 'console.identityImport.policy.', + vocabulary: { module: 'packages/app-shell/src/views/identityImport.ts', name: 'IdentityPasswordPolicy', kind: 'union' }, + }, + { + head: 'console.identityImport.policyHint.', + vocabulary: { module: 'packages/app-shell/src/views/identityImport.ts', name: 'IdentityPasswordPolicy', kind: 'union' }, + }, + { + head: 'console.settingsHub.categories.', + enumerable: false, + why: 'runtime-data', + reason: + 'The category is a free string off each settings manifest, grouped at render time. ' + + 'Any plugin can ship a new one, so the set is not knowable from this repo at all — ' + + "the call site's `defaultValue: category` is the correct treatment, not a gate entry.", + }, + { + head: 'dashboard.filters.range.', + enumerable: false, + why: 'external-vocabulary', + reason: + 'The presets the bar renders are `DATE_RANGE_PRESETS` from `@objectstack/spec/ui`, a ' + + 'dependency. This reader reads repo source only (see readVocabulary), and the repo has ' + + 'no exhaustive `Record` to read instead. ' + + '`packages/types/src/data-protocol.ts`\'s `FilterBuilderDateRangePreset` is a DIFFERENT ' + + 'vocabulary (the filter builder\'s) and using it here would be a guess, which is worse ' + + 'than this declaration.', + }, + { + head: 'dashboard.trend.', + vocabulary: { module: 'packages/plugin-dashboard/src/DatasetWidget.tsx', name: 'TREND_LABEL_DEFAULTS', kind: 'objectKeys' }, + }, + { + head: 'filterBuilder.operators.', + vocabulary: { module: 'packages/components/src/custom/filter-builder.tsx', name: 'defaultOperators', kind: 'arrayField', field: 'value' }, + }, + { + head: 'gantt.link.rejected.', + vocabulary: { module: 'packages/plugin-gantt/src/GanttView.tsx', name: 'GanttLinkRejection', kind: 'union' }, + }, + { + head: 'gantt.linkEnd.', + enumerable: false, + why: 'unnamed-union', + reason: + "`endLabel(e: 'start' | 'end')` and the `linkDrag` state's `sourceEnd`/`targetEnd` both " + + 'spell the union inline; GanttView exports `GanttLinkType` and `GanttLinkRejection` but ' + + 'no endpoint type. Naming it would make this family exactly checkable.', + }, + { + head: 'gantt.linkType.', + vocabulary: { module: 'packages/plugin-gantt/src/GanttView.tsx', name: 'GanttLinkType', kind: 'union' }, + }, + { + head: 'gantt.viewMode.', + vocabulary: { module: 'packages/plugin-gantt/src/GanttView.tsx', name: 'GanttViewMode', kind: 'union' }, + }, + { + head: 'grid.import.confidence.', + vocabulary: { module: 'packages/plugin-grid/src/importParsers.ts', name: 'MappingConfidence', kind: 'union' }, + }, + { + head: 'grid.import.jobStatus.', + // `ImportJobStatus` itself is a Zod enum in `@objectstack/spec/api`, out of + // this reader's reach — but `IMPORT_JOB_STATUS_VARIANT` is declared + // `Record`, so tsc already requires its keys to be + // exactly that union. Reading the Record is reading the union, with the + // exhaustiveness enforced by the type checker this file does not run. + vocabulary: { module: 'packages/plugin-grid/src/ImportWizard.tsx', name: 'IMPORT_JOB_STATUS_VARIANT', kind: 'objectKeys' }, + }, + { + head: 'grid.import.type.', + vocabulary: { module: 'packages/plugin-grid/src/importParsers.ts', name: 'InferredType', kind: 'union' }, + }, + { + head: 'home.recentApps.itemType.', + vocabulary: { module: 'packages/app-shell/src/context/RecentItemsProvider.tsx', name: 'RecentItem', kind: 'interfaceField', field: 'type' }, + }, + { + head: 'managedByBadge.', + // The member is `variant.i18nKey`, NOT the `VARIANTS` key — the two agree + // today and the gate must not assume they will, so the field is read. + vocabulary: { module: 'packages/app-shell/src/components/ManagedByBadge.tsx', name: 'VARIANTS', kind: 'objectField', field: 'i18nKey' }, + }, + { + head: 'marketplace.category.', + enumerable: false, + why: 'runtime-data', + reason: + '`MarketplacePackage.category` is `string | null` off the registry API — a marketplace ' + + 'the platform does not own decides the set. The 15 members `en` carries are a curated ' + + 'subset, not the vocabulary.', + }, + { + head: 'marketplace.disclosure.runtime.', + vocabulary: { module: 'packages/app-shell/src/console/marketplace/PluginDisclosure.tsx', name: 'RUNTIME_FALLBACK', kind: 'objectKeys' }, + }, + { + head: 'organization.invitations.status.', + vocabulary: { module: 'packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx', name: 'StatusFilter', kind: 'union' }, + }, + { + head: 'report.aggregate.', + enumerable: false, + why: 'external-vocabulary', + reason: + "The aggregate name comes from a chart series' `aggregate`, whose vocabulary is the " + + "spec's chart-aggregate enum in `@objectstack/spec/ui`. No repo-local exhaustive Record " + + 'mirrors it, so there is nothing here to read.', + }, +]; + +/** + * Read the literal members of a named declaration, from source. + * + * Returns `null` when the declaration is not found or is not the declared + * shape — which the caller reports rather than absorbs, because a registry + * entry pointing at a moved or rewritten declaration must not silently degrade + * into "no members to check". + * + * @param {string} root + * @param {VocabularySpec} spec + * @returns {string[] | null} + */ +export function readVocabulary(root, spec) { + const file = join(root, spec.module); + if (!existsSync(file)) return null; + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); + + const unwrap = (node) => { + let n = node; + while (n && (ts.isAsExpression(n) || ts.isParenthesizedExpression(n) || (ts.isSatisfiesExpression?.(n) ?? false))) { + n = n.expression; + } + return n; + }; + const literal = (node) => { + const inner = unwrap(node); + return inner && (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner)) ? inner.text : null; + }; + const propertyName = (prop) => + ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) || ts.isNumericLiteral(prop.name) + ? prop.name.text + : null; + + let found = null; + const visit = (node) => { + if (found) return; + if (ts.isTypeAliasDeclaration(node) && node.name.text === spec.name) { + // A `type X = { … }` object literal type answers `interfaceField` too, so + // the registry never has to know which of the two spellings a shape uses. + found = ts.isTypeLiteralNode(node.type) ? { type: node.type, members: node.type.members } : { type: node.type }; + } else if (ts.isInterfaceDeclaration(node) && node.name.text === spec.name) { + found = { members: node.members }; + } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === spec.name) { + found = { value: node.initializer ? unwrap(node.initializer) : null }; + } + if (!found) ts.forEachChild(node, visit); + }; + visit(source); + if (!found) return null; + + const members = []; + if (spec.kind === 'union' || spec.kind === 'interfaceField') { + let type = found.type; + if (spec.kind === 'interfaceField') { + const members = found.members; + if (!members) return null; + const property = members.find( + (m) => ts.isPropertySignature(m) && m.name && (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) && m.name.text === spec.field, + ); + if (!property || !property.type) return null; + type = property.type; + } + if (!type) return null; + // A single-member union is a bare LiteralType, not a UnionType. + const arms = ts.isUnionTypeNode(type) ? type.types : [type]; + for (const arm of arms) { + if (!ts.isLiteralTypeNode(arm) || !ts.isStringLiteral(arm.literal)) return null; + members.push(arm.literal.text); + } + return members; + } + + const value0 = found.value; + if (!value0) return null; + let value = value0; + if (spec.kind === 'set') { + if (!ts.isNewExpression(value) || !value.arguments || value.arguments.length !== 1) return null; + value = unwrap(value.arguments[0]); + } + + if (spec.kind === 'array' || spec.kind === 'set') { + if (!value || !ts.isArrayLiteralExpression(value)) return null; + for (const element of value.elements) { + const text = literal(element); + if (text === null) return null; + members.push(text); + } + return members; + } + if (spec.kind === 'arrayField') { + if (!ts.isArrayLiteralExpression(value)) return null; + for (const element of value.elements) { + const object = unwrap(element); + if (!object || !ts.isObjectLiteralExpression(object)) return null; + const prop = object.properties.find((p) => ts.isPropertyAssignment(p) && propertyName(p) === spec.field); + if (!prop) return null; + const text = literal(prop.initializer); + if (text === null) return null; + members.push(text); + } + return members; + } + if (spec.kind === 'objectKeys' || spec.kind === 'objectField') { + if (!ts.isObjectLiteralExpression(value)) return null; + for (const prop of value.properties) { + if (!ts.isPropertyAssignment(prop)) return null; + const name = propertyName(prop); + if (name === null) return null; + if (spec.kind === 'objectKeys') { + members.push(name); + continue; + } + const nested = unwrap(prop.initializer); + if (!nested || !ts.isObjectLiteralExpression(nested)) return null; + const inner = nested.properties.find((p) => ts.isPropertyAssignment(p) && propertyName(p) === spec.field); + if (!inner) return null; + const text = literal(inner.initializer); + if (text === null) return null; + members.push(text); + } + return members; + } + return null; +} + /** 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?$/; @@ -927,13 +1318,41 @@ function staticHead(argument) { return inner.head.text; } +/** + * The head/tail shape of a template key (objectui#4964). + * + * `` `managedByBadge.${v.i18nKey}.short` `` is head `managedByBadge.`, tail + * `.short`, one substitution — so a member `config` expands to the full key + * `managedByBadge.config.short`, and checking the head plus the member alone + * would ask for a BRANCH that is not the leaf the call site renders. + * + * `tail` is reported only for a SINGLE-substitution template. With two or more + * (`` `${ns}.${suffix}` ``) there is no one member position, so no expansion + * this file could build is the key — those sites are counted, never expanded. + * + * @returns {{ head: string, tail: string | null } | null} + */ +function templateShape(argument) { + const inner = unwrapExpression(argument); + if (!inner || !ts.isTemplateExpression(inner)) return null; + const head = inner.head.text; + if (inner.templateSpans.length !== 1) return { head, tail: null }; + return { head, tail: inner.templateSpans[0].literal.text }; +} + // ── the analysis ───────────────────────────────────────────────────────────── /** + * `families` is injectable so the synthetic-repo tests can pin the registry + * RULES against a registry they control. The real run always uses the module + * constant — nothing in this file reads a registry from disk, so there is no + * configuration path a call site could quietly narrow. + * * @returns {{ findings: Array, counters: Record, enKeyCount: number, - * referencedKeys: Set, referencedBranches: Set, dynamicHeads: Set }} + * referencedKeys: Set, referencedBranches: Set, dynamicHeads: Set, + * dynamicFamilies: Map, sites: Array, multiSubstitution: number }> }} */ -export function analyze(root) { +export function analyze(root, /** @type {{ families?: DynamicKeyFamily[] }} */ { families = DYNAMIC_KEY_FAMILIES } = {}) { const { leaves, branches, values } = 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 @@ -963,6 +1382,15 @@ export function analyze(root) { const referencedBranches = new Set(); const dynamicHeads = new Set(); + // objectui#4964 — per-head census of the PACK-backed template call sites, so + // the registry below is evaluated against what the scan actually saw rather + // than against itself. Keyed by static head; `tails` holds the literal text + // after the single substitution (`.short` for + // `` `managedByBadge.${v.i18nKey}.short` ``), `''` when the substitution ends + // the template. + /** @type {Map, sites: Array, multiSubstitution: number }>} */ + const dynamicFamilies = new Map(); + const findings = []; const counters = { filesScanned: 0, @@ -971,6 +1399,12 @@ export function analyze(root) { literalKeys: 0, resolvedKeys: 0, dynamicKeySites: 0, + headlessDynamicKeySites: 0, + declaredFamilies: 0, + enumerableFamilies: 0, + notEnumerableFamilies: 0, + checkedMembers: 0, + unexpandableFamilySites: 0, probeSites: 0, skippedLocalTable: 0, skippedNotATranslator: 0, @@ -1217,6 +1651,24 @@ export function analyze(root) { // recorded even when `head` matches nothing today, which is // harmless (nothing in `en` starts with it either). if (head) dynamicHeads.add(head); + + // objectui#4964 — the family census the exact-member check runs on. + // Recorded for EVERY pack-backed template site, including the ones + // whose head matches nothing, so the registry describes the same + // set the prefix rule sees rather than a subset of it. + const shape = templateShape(argument); + if (shape && shape.head) { + const family = dynamicFamilies.get(shape.head) ?? { tails: new Set(), sites: [], multiSubstitution: 0 }; + if (shape.tail === null) family.multiSubstitution += 1; + else family.tails.add(shape.tail); + family.sites.push(at); + dynamicFamilies.set(shape.head, family); + } else if (!head) { + // Not a template at all — `t(key)` on a variable. There is no + // static head, so neither the prefix rule nor this one can say + // anything; the counter is the only visible trace. + counters.headlessDynamicKeySites += 1; + } } } } @@ -1225,16 +1677,96 @@ export function analyze(root) { visit(source); } - return { findings, counters, enKeyCount: leaves.size, referencedKeys, referencedBranches, dynamicHeads }; + // ── the dynamic-family registry, evaluated (objectui#4964) ───────────────── + // + // Three rules, and the order matters: the two ratchet directions run over the + // census and the registry FIRST, so a family that is undeclared or stale is + // reported as itself rather than as an absence of member findings. Only then + // are the declared vocabularies expanded. The prefix rule above has already + // run for every one of these heads and is not consulted here — this class can + // only ADD findings to a family, never take the prefix check away from one. + const declaredHeads = new Set(); + for (const family of families) { + if (declaredHeads.has(family.head)) { + findings.push({ reason: 'duplicate-family', file: 'scripts/check-i18n-call-site-keys.mjs', line: 0, detail: family.head }); + continue; + } + declaredHeads.add(family.head); + counters.declaredFamilies += 1; + + const observed = dynamicFamilies.get(family.head); + if (!observed) { + // Stale: the last call site with this head is gone. Deleting the entry is + // the fix, and failing on it is what keeps the list a description of the + // repo instead of an accumulating wishlist. + findings.push({ reason: 'stale-dynamic-family', file: 'scripts/check-i18n-call-site-keys.mjs', line: 0, detail: family.head }); + continue; + } + if (family.enumerable === false) { + counters.notEnumerableFamilies += 1; + continue; + } + counters.enumerableFamilies += 1; + + const members = readVocabulary(root, family.vocabulary); + if (members === null) { + // The declaration moved, was renamed, or is no longer the declared shape. + // Reported, never absorbed: silently reading it as "no members" is + // exactly the vacuous-green this class exists to make impossible. + findings.push({ + reason: 'unreadable-vocabulary', + ...observed.sites[0], + detail: family.head, + expected: `${family.vocabulary.kind} ${family.vocabulary.name} in ${family.vocabulary.module}`, + }); + continue; + } + if (members.length === 0) { + findings.push({ + reason: 'empty-vocabulary', + ...observed.sites[0], + detail: family.head, + expected: `${family.vocabulary.kind} ${family.vocabulary.name} in ${family.vocabulary.module}`, + }); + continue; + } + + counters.unexpandableFamilySites += observed.multiSubstitution; + const missing = []; + for (const tail of [...observed.tails].sort()) { + for (const member of members) { + const key = `${family.head}${member}${tail}`; + counters.checkedMembers += 1; + if (!resolvesLeaf(key)) missing.push(key); + } + } + // One finding PER missing key, not one per family: the baseline is keyed by + // the exact key, the same as classes 1 and 2, so a family paying off three + // of five members shrinks the file by three lines instead of staying whole. + for (const key of missing.sort()) { + findings.push({ reason: 'missing-member', ...observed.sites[0], detail: key, expected: family.head }); + } + } + + for (const [head, observed] of dynamicFamilies) { + if (declaredHeads.has(head)) continue; + findings.push({ reason: 'undeclared-dynamic-family', ...observed.sites[0], detail: head }); + } + + return { findings, counters, enKeyCount: leaves.size, referencedKeys, referencedBranches, dynamicHeads, dynamicFamilies }; } // ── baseline ───────────────────────────────────────────────────────────────── export function readBaseline(root) { const file = join(root, 'scripts/i18n-call-site-key-baseline.json'); - if (!existsSync(file)) return { missingKeys: {}, missingPrefixes: {} }; + if (!existsSync(file)) return { missingKeys: {}, missingPrefixes: {}, missingMembers: {} }; const parsed = JSON.parse(readFileSync(file, 'utf8')); - return { missingKeys: parsed.missingKeys ?? {}, missingPrefixes: parsed.missingPrefixes ?? {} }; + return { + missingKeys: parsed.missingKeys ?? {}, + missingPrefixes: parsed.missingPrefixes ?? {}, + missingMembers: parsed.missingMembers ?? {}, + }; } /** @@ -1246,22 +1778,28 @@ export function applyBaseline(findings, baseline) { const unexpected = []; const seenKeys = new Set(); const seenPrefixes = new Set(); + const seenMembers = new Set(); for (const finding of findings) { - if (finding.reason === 'missing-key' && Object.hasOwn(baseline.missingKeys, finding.detail)) { + 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)) { + if (finding.reason === 'missing-prefix' && Object.hasOwn(baseline.missingPrefixes ?? {}, finding.detail)) { seenPrefixes.add(finding.detail); continue; } + if (finding.reason === 'missing-member' && Object.hasOwn(baseline.missingMembers ?? {}, finding.detail)) { + seenMembers.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 })), + ...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 })), + ...Object.keys(baseline.missingMembers ?? {}).filter((m) => !seenMembers.has(m)).map((entry) => ({ kind: 'missingMembers', entry })), ]; return { unexpected, stale }; @@ -1281,6 +1819,42 @@ const HINTS = { '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`.', + 'missing-member': + 'The template family\'s head resolves, so `missing-prefix` is satisfied — but this' + + ' SPECIFIC member of the vocabulary the call site iterates has no leaf in `en`' + + ' (objectui#4964). Ten packs missing it identically is full parity, so neither pack' + + ' gate can see it and the user reads the raw key. Add the key to' + + ' `packages/i18n/src/locales/en.ts`, which makes `all-locales-key-parity.test.ts`' + + ' demand it in the other nine packs. If the member is genuinely unreachable at' + + ' runtime, the fix is in the VOCABULARY (delete the dead member), never in this' + + ' registry — narrowing a declared vocabulary to make a red go away is how an exact' + + ' check silently becomes a smaller one.', + 'undeclared-dynamic-family': + 'A pack-backed template key whose static head is not in DYNAMIC_KEY_FAMILIES' + + ' (objectui#4964). Prefix-checking alone cannot see a member missing from all ten' + + ' packs, so every family must say how its member set is known: add an entry with a' + + ' `vocabulary` naming the declaration the call site iterates (a union, a const array,' + + ' an object table), or — if the substitution genuinely has no static member set —' + + ' `enumerable: false` with the reason. `enumerable: false` is a real answer and is' + + ' preferred over a guessed vocabulary; what is not allowed is silence.', + 'stale-dynamic-family': + 'A DYNAMIC_KEY_FAMILIES entry whose head no longer appears at any pack-backed call' + + ' site. Delete the entry — the registry describes the repo, and an entry nothing' + + ' exercises is an exact check running against nothing.', + 'duplicate-family': + 'Two DYNAMIC_KEY_FAMILIES entries declare the same head. Only the first would be' + + ' evaluated, so the second is either dead or a contradiction. Merge them.', + 'unreadable-vocabulary': + 'The declaration this family names could not be read as the `kind` it declares — it' + + ' moved, was renamed, or was rewritten into a shape this reader does not parse' + + ' (a spread, a computed member, a derived expression). Reported rather than absorbed:' + + ' reading it as "no members" would turn the exact check vacuous while the run stayed' + + ' green. Repoint the entry, or change its `kind`.', + 'empty-vocabulary': + 'The declaration this family names resolved to ZERO members, so the exact check would' + + ' assert nothing while reading exactly like a passing one. Either the declaration is' + + ' genuinely empty (delete the family, or the call site) or the reader picked up the' + + ' wrong binding.', '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' + @@ -1371,6 +1945,13 @@ if (invokedDirectly) { `${counters.unjudgedInterpolation} with no single comparable en value, ${counters.opaqueOptions} with an ` + `unreadable option set, ${EXTERNALLY_INTERPOLATED_HOLES.length} key(s) whose holes are filled downstream.`, ); + console.log( + `Dynamic key families: ${counters.declaredFamilies} declared — ${counters.enumerableFamilies} with a ` + + `static vocabulary (${counters.checkedMembers} member key(s) checked exactly), ` + + `${counters.notEnumerableFamilies} with no enumerable member set (prefix-checked only), ` + + `${counters.unexpandableFamilySites} multi-substitution site(s) not expandable, ` + + `${counters.headlessDynamicKeySites} dynamic call site(s) with no static head at all.`, + ); console.log( `Sibling fallbacks: ${counters.siblingFallbacks} call site(s) sit left of a ||/?? — ` + `${counters.judgedSiblingFallbacks} judged, ${counters.computedSiblingFallbacks} with a non-literal ` + @@ -1385,15 +1966,30 @@ if (invokedDirectly) { const drift = unexpected.filter((finding) => finding.reason === 'default-value-drift'); const parity = unexpected.filter((finding) => finding.reason === 'interpolation-parity'); const siblings = unexpected.filter((finding) => finding.reason === 'dead-sibling-fallback'); + // objectui#4964's classes read on their own too: they are all about a template + // family whose HEAD resolves, which is precisely the case the two key classes + // above declare out of scope. + const FAMILY_CLASSES = new Set([ + 'missing-member', + 'undeclared-dynamic-family', + 'stale-dynamic-family', + 'duplicate-family', + 'unreadable-vocabulary', + 'empty-vocabulary', + ]); + const families = unexpected.filter((finding) => FAMILY_CLASSES.has(finding.reason)); const VALUE_CLASSES = new Set(['default-value-drift', 'interpolation-parity', 'dead-sibling-fallback']); - const keyFindings = unexpected.filter((finding) => !VALUE_CLASSES.has(finding.reason)); + const keyFindings = unexpected.filter( + (finding) => !VALUE_CLASSES.has(finding.reason) && !FAMILY_CLASSES.has(finding.reason), + ); if (unexpected.length === 0 && stale.length === 0) { console.log( `Every in-scope call-site key resolves against the en pack (${enKeyCount} keys), every` + ' literal inline defaultValue matches the value the pack serves, every call site passes' + - ' exactly the arguments that value has holes for, and no call site carries a literal' + - ' fallback beside itself.', + ' exactly the arguments that value has holes for, no call site carries a literal' + + ' fallback beside itself, and every dynamic key family either checks its members' + + ' against a declared vocabulary or says in writing why it has none.', ); process.exit(0); } @@ -1454,6 +2050,17 @@ if (invokedDirectly) { } } + if (families.length > 0) { + console.error( + `\n${families.length} dynamic-family finding${families.length === 1 ? '' : 's'} — the head resolves, so the` + + ' prefix rule is satisfied; these are about the MEMBERS behind it:', + ); + for (const finding of families) { + console.error(` ${finding.file}:${finding.line}:${finding.column} [${finding.reason}] ${finding.detail}`); + if (finding.expected) console.error(` ${finding.expected}`); + } + } + for (const reason of Object.keys(HINTS)) { if (unexpected.some((finding) => finding.reason === reason)) console.error(`\n${reason}: ${HINTS[reason]}`); } diff --git a/scripts/i18n-call-site-key-baseline.json b/scripts/i18n-call-site-key-baseline.json index 2b6c13570b..1d59f70f50 100644 --- a/scripts/i18n-call-site-key-baseline.json +++ b/scripts/i18n-call-site-key-baseline.json @@ -7,14 +7,33 @@ "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).", - "BOTH LISTS ARE NOW EMPTY -- the 258-key stock objectui#3546 opened with was paid off", - "across seven slices, the last of them the two template families below. Keep the file:", - "empty is its terminal, load-bearing state. Any NEW unresolved call-site key is", - "`unexpected` against an empty baseline and fails the build, which is the point." + "missingKeys AND missingPrefixes ARE EMPTY -- the 258-key stock objectui#3546 opened with", + "was paid off across seven slices, the last of them two template families. Keep both lists:", + "empty is their terminal, load-bearing state. Any NEW unresolved call-site key is", + "`unexpected` against an empty baseline and fails the build, which is the point.", + "missingMembers is the third list (objectui#4964) and is NOT empty: it holds debt the", + "exact-member check MEASURED on its first run, not debt this repo took on." ], "missingKeys": {}, "//": "Template keys whose static head matches no en key at all, so every expansion misses.", - "missingPrefixes": {} + "missingPrefixes": {}, + + "//missingMembers": [ + "objectui#4964 -- a template family whose HEAD resolves (so missingPrefixes is satisfied)", + "and whose declared vocabulary names a member `en` does not define. Ten packs missing it", + "identically is full parity, so no pack gate can see it; this is the class that check found.", + "Same ratchet rules as the two lists above: an entry that no longer fires fails the build.", + "Fix by adding the key to packages/i18n/src/locales/en.ts (which then makes", + "all-locales-key-parity.test.ts demand it in the other nine packs) and deleting the line", + "here -- NOT by narrowing the vocabulary the family declares, which would shrink the check", + "rather than pay off the debt.", + "Both entries below were measured by the check's first run, not created by it." + ], + + "missingMembers": { + "home.recentApps.itemType.metadata": "objectui#6023", + "home.recentApps.itemType.report": "objectui#6023" + } } From 35c1adabd1eebebcb32b1379284015aa2529eb86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 14:20:27 +0000 Subject: [PATCH 2/2] fix(i18n): keep #3546's BOTH LISTS sentinel in the baseline note, scoped to its pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third-list rewrite dropped the literal string `BOTH LISTS ARE NOW EMPTY`, which `residue-namespaces-3546.test.tsx:792` reads. That assertion is #3546's guard: a reader who finds two empty objects must not conclude the ratchet is obsolete, so the note has to keep saying so in those words. Restored inside a sentence that names WHICH two lists, because with a third list present "both" alone would be actively misleading — the pair missingKeys + missingPrefixes is the one #3546 ratcheted to zero, and the missingMembers paragraph now says explicitly that it is outside that pair and not empty. The guard's own line is the only thing this touches; the test file is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- scripts/i18n-call-site-key-baseline.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/i18n-call-site-key-baseline.json b/scripts/i18n-call-site-key-baseline.json index 1d59f70f50..d230f77246 100644 --- a/scripts/i18n-call-site-key-baseline.json +++ b/scripts/i18n-call-site-key-baseline.json @@ -7,12 +7,14 @@ "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 AND missingPrefixes ARE EMPTY -- the 258-key stock objectui#3546 opened with", - "was paid off across seven slices, the last of them two template families. Keep both lists:", - "empty is their terminal, load-bearing state. Any NEW unresolved call-site key is", - "`unexpected` against an empty baseline and fails the build, which is the point.", - "missingMembers is the third list (objectui#4964) and is NOT empty: it holds debt the", - "exact-member check MEASURED on its first run, not debt this repo took on." + "Of the three lists in this file, missingKeys and missingPrefixes are the PAIR that", + "objectui#3546 ratcheted to zero -- BOTH LISTS ARE NOW EMPTY. The 258-key stock it opened", + "with was paid off across seven slices, the last of them two template families. Keep both", + "of that pair: empty is their terminal, load-bearing state. Any NEW unresolved call-site", + "key is `unexpected` against an empty baseline and fails the build, which is the point.", + "missingMembers is a THIRD list (objectui#4964), NOT part of that pair and NOT empty -- so", + "the sentence above counts missingKeys and missingPrefixes only, never all three. It holds", + "debt the exact-member check MEASURED on its first run, not debt this repo took on." ], "missingKeys": {},