diff --git a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts index dfcf511bf1c..bbc4109db40 100644 --- a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts +++ b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -17,6 +17,7 @@ import { holesOf, PACK_HOOK, PACK_NAMESPACE_DIRS, + PACK_NAMESPACE_FRAMES, readVocabulary, RESERVED_OPTION_NAMES, unresolvableSpellings, @@ -302,6 +303,120 @@ describe('the pack-shaped namespaces are part of the en pack (objectstack#30588) }); }); +describe('a key only a namespace defines is refused outside its frame (objectstack#30645)', () => { + const NS_EN = `${PACK_NAMESPACE_DIRS[0]}en.ts`; + const FRAME = PACK_NAMESPACE_FRAMES[PACK_NAMESPACE_DIRS[0]][0]; + const NS_PACK = + "const en = { shop: { title: 'Shop', row_one: '{{count}} row', row_other: '{{count}} rows', " + + "status: { open: 'Open', closed: 'Closed' } } } as const;\nexport default en;\n"; + const RESIDENT = "const en = { shop: { kept: 'Kept', tag: { a: 'A' } } } as const;\nexport default en;\n"; + const page = (body: string) => + `import { useObjectTranslation } from '${I18N_PKG}';\nexport function P({ s }: { s: string }) { const { t } = useObjectTranslation(); return ${body}; }\n`; + const outsideFrame = (root: string) => findingsOf(root, 'namespace-key-outside-frame'); + + it('every namespace directory is declared with a non-empty frame (one list, read both ways)', () => { + expect(Object.keys(PACK_NAMESPACE_FRAMES)).toEqual([...PACK_NAMESPACE_DIRS]); + for (const dir of PACK_NAMESPACE_DIRS) expect(PACK_NAMESPACE_FRAMES[dir].length).toBeGreaterThan(0); + }); + + it('turns red on a namespace-only key spelled outside the frame (the positive control)', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + 'packages/studio/src/page.tsx': page("t('shop.title')"), + }); + // fixture-address: the finding the gate reports for the fixture page written just above + expect(outsideFrame(root)).toEqual(['shop.title@packages/studio/src/page.tsx:2']); + const finding = analyze(root, { families: [] }).findings.find( + (f: { reason: string }) => f.reason === 'namespace-key-outside-frame', + ); + expect(finding?.expected).toBe(PACK_NAMESPACE_DIRS[0]); + }); + + it('reads the same key inside the frame, and counts it there', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + [`${FRAME}Page.tsx`]: page("t('shop.title')"), + }); + expect(outsideFrame(root)).toEqual([]); + expect(analyze(root, { families: [] }).framedReads.get(PACK_NAMESPACE_DIRS[0])).toBe(1); + }); + + it('stays silent on a resident key outside the frame', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + 'packages/studio/src/page.tsx': page("t('shop.kept')"), + }); + expect(outsideFrame(root)).toEqual([]); + expect(analyze(root, { families: [] }).framedReads.get(PACK_NAMESPACE_DIRS[0])).toBe(0); + }); + + it('refuses a plural base whose forms live only in the namespace', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + 'packages/studio/src/page.tsx': page("t('shop.row', { count: 2 })"), + }); + // fixture-address: the finding the gate reports for the fixture page written just above + expect(outsideFrame(root)).toEqual(['shop.row@packages/studio/src/page.tsx:2']); + }); + + it('refuses a template head only the namespace answers, and passes one a resident leaf answers', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + 'packages/studio/src/a.tsx': page('t(`shop.status.${s}`)'), + 'packages/studio/src/b.tsx': page('t(`shop.tag.${s}`)'), + }); + // fixture-address: the finding the gate reports for the fixture page written just above + expect(outsideFrame(root)).toEqual(['shop.status.*@packages/studio/src/a.tsx:2']); + }); + + it('refuses a returnObjects subtree that only the namespace fills', () => { + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [NS_EN]: NS_PACK, + 'packages/studio/src/page.tsx': page("t('shop.status', { returnObjects: true })"), + }); + // fixture-address: the finding the gate reports for the fixture page written just above + expect(outsideFrame(root)).toEqual(['shop.status.*@packages/studio/src/page.tsx:2']); + }); + + it('a second namespace inherits the rule from its declaration alone', () => { + const SECOND = 'packages/i18n/src/namespaces/billing/'; + const frames = { ...PACK_NAMESPACE_FRAMES, [SECOND]: ['packages/app-shell/src/console/billing/'] }; + const root = repoWith({ + 'packages/i18n/src/locales/en.ts': RESIDENT, + [`${SECOND}en.ts`]: "const en = { billing: { title: 'Billing' } } as const;\nexport default en;\n", + 'packages/app-shell/src/console/billing/Page.tsx': page("t('billing.title')"), + 'packages/studio/src/page.tsx': page("t('billing.title')"), + }); + const result = analyze(root, { families: [], frames }); + const refused = result.findings + .filter((f: { reason: string }) => f.reason === 'namespace-key-outside-frame') + .map((f: { detail: string; file: string; expected: string }) => `${f.detail}@${f.file} <- ${f.expected}`); + expect(refused).toEqual([`billing.title@packages/studio/src/page.tsx <- ${SECOND}`]); + expect(result.framedReads.get(SECOND)).toBe(1); + }); + + it('refuses a namespace declared without a frame', () => { + const root = repoWith({ 'packages/i18n/src/locales/en.ts': RESIDENT }); + expect(() => analyze(root, { families: [], frames: { 'packages/i18n/src/namespaces/billing/': [] } })).toThrow( + /declares no frame/, + ); + }); + + it('on this tree: no namespace key is read outside its frame, and every frame reads some', () => { + const outside = REPO_ANALYSIS.findings.filter( + (f: { reason: string }) => f.reason === 'namespace-key-outside-frame', + ); + expect(outside).toEqual([]); + for (const dir of PACK_NAMESPACE_DIRS) expect(REPO_ANALYSIS.framedReads.get(dir)).toBeGreaterThan(0); + }); +}); + describe('the file walk', () => { it('reads sources but not tests, type declarations or build output', () => { const root = repoWith({ diff --git a/ui/scripts/check-i18n-call-site-keys.mjs b/ui/scripts/check-i18n-call-site-keys.mjs index b52a5cd77c4..0f9a126d30b 100644 --- a/ui/scripts/check-i18n-call-site-keys.mjs +++ b/ui/scripts/check-i18n-call-site-keys.mjs @@ -120,7 +120,7 @@ * it, and the rule is the class rather than the instance: the first full run * found 24 such sites in four files, every one of them on a key `en` defines. * - * ## Eight failure classes + * ## Nine failure classes * * (The heading said "Five" while the list below held seven — a stale count * corrected here rather than left to grow by one more.) @@ -481,6 +481,24 @@ * moved to JSON on objectui#6923's ruling instead of being duplicated here * and pinned. * + * 9. `namespace-key-outside-frame` (objectstack#30645) — the key EXISTS, but + * only in a lazy pack-shaped namespace, and the call site is outside the + * directories that namespace's frame wraps (`PACK_NAMESPACE_FRAMES`). Every + * other class reads the namespace as part of the `en` pack, which is right + * under the frame and wrong everywhere else: nothing fetches the namespace + * there, so the user reads the raw key. Judged on a literal key (plural + * forms included: a key is namespace-only when every form that resolves is), + * on a `returnObjects` subtree and on a template head, each refused only + * when NO resident leaf answers it. Not judged: a key built by a helper or + * spelled in a table and passed as a variable — the same blind spot class 1 + * has. Inside the frame's directories, which files a page OUTSIDE the frame + * imports is the namespace's own boundary test's census, not this gate's. + * The rule reads the frame off the one namespace list, so a second namespace + * is judged the day it is declared. HARD from day one (0 on the tree it + * landed against); the run's `Namespace frames:` line prints, per + * namespace, how many call-site keys its frame reads, and a namespace whose + * frame reads none fails the run as a collapsed scan. + * * ## Dynamic keys: the explicit policy * * A key that is not a string literal cannot be resolved statically. Those call @@ -1325,8 +1343,23 @@ function staticTextSegments(node, source) { * is a directory of partial packs; the `studio` namespace is NOT one (it is a * `{token}` string table of its own, read by `collectDesignerKeys` in * `check-i18n-dead-keys.mjs`). + * + * Each directory is declared WITH ITS FRAME: the source directories (relative + * to `ui/`) whose files render under the gate that fetches the namespace. A key + * only the namespace defines is a raw key anywhere else, so class 9 + * (`namespace-key-outside-frame`) refuses it at a call site outside these + * directories. The directory is the frame's outer bound; which files INSIDE it + * a page outside the frame imports is the census of that namespace's boundary + * test (`marketplaceNamespaceBoundary.test.ts` for this one). A namespace is + * added here or nowhere, so it cannot arrive without its frame. */ -export const PACK_NAMESPACE_DIRS = Object.freeze(['packages/i18n/src/namespaces/marketplace/']); +/** @type {Readonly>} */ +export const PACK_NAMESPACE_FRAMES = Object.freeze({ + 'packages/i18n/src/namespaces/marketplace/': Object.freeze(['packages/app-shell/src/console/marketplace/']), +}); + +/** The pack-shaped namespace directories: the keys of {@link PACK_NAMESPACE_FRAMES}. */ +export const PACK_NAMESPACE_DIRS = Object.freeze(Object.keys(PACK_NAMESPACE_FRAMES)); /** Every directory a pack key is DEFINED in: the packs, then the namespaces. */ export const PACK_DEFINITION_DIRS = Object.freeze(['packages/i18n/src/locales/', ...PACK_NAMESPACE_DIRS]); @@ -1348,21 +1381,28 @@ export const PACK_DEFINITION_DIRS = Object.freeze(['packages/i18n/src/locales/', * A leaf defined in two of the files — or a leaf in one that is a group in * another — THROWS: two homes for one key cannot both be what renders. * - * @returns {{ leaves: Set, branches: Set, values: Map }} + * `namespaceOf` maps every leaf a namespace file defines to that namespace's + * directory; a leaf absent from it is resident in the eager pack. + * + * @param {readonly string[]} [namespaceDirs] the namespace directories to read (default {@link PACK_NAMESPACE_DIRS}) + * @returns {{ leaves: Set, branches: Set, values: Map, namespaceOf: Map }} */ -export function collectEnKeys(root) { +export function collectEnKeys(root, namespaceDirs = PACK_NAMESPACE_DIRS) { const leaves = new Set(); const branches = new Set(); const values = new Map(); const homeOf = new Map(); + const namespaceOf = new Map(); // The pack is required; a namespace's `en` file is read when it exists. A // missing one cannot hide anything: its keys simply are not defined, so every // call site that asks for one is reported as asking for a missing key. const files = [ - join(root, 'packages/i18n/src/locales/en.ts'), - ...PACK_NAMESPACE_DIRS.map((dir) => join(root, dir, 'en.ts')).filter((file) => existsSync(file)), + { file: join(root, 'packages/i18n/src/locales/en.ts'), namespace: null }, + ...namespaceDirs + .map((dir) => ({ file: join(root, dir, 'en.ts'), namespace: dir })) + .filter(({ file }) => existsSync(file)), ]; - for (const file of files) { + for (const { file, namespace } of files) { const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); const unwrap = (node) => { @@ -1416,6 +1456,7 @@ export function collectEnKeys(root) { } leaves.add(path); homeOf.set(path, file); + if (namespace !== null) namespaceOf.set(path, namespace); const text = staticString(value, source); if (text !== null) values.set(path, text); } @@ -1423,7 +1464,7 @@ export function collectEnKeys(root) { }; walk(literal, ''); } - return { leaves, branches, values }; + return { leaves, branches, values, namespaceOf }; } // ── source walk ────────────────────────────────────────────────────────────── @@ -1963,7 +2004,7 @@ function resolveFactoryTable(root, source, argument, parseModule) { // ── the analysis ───────────────────────────────────────────────────────────── /** - * `families` and `handRolled` are injectable so the synthetic-repo tests can pin + * `families`, `handRolled` and `frames` are injectable so the synthetic-repo tests can pin * the registry RULES against a registry they control. The real run always uses * the module constants, and every CLI path below calls `analyze(root)` with no * options at all — so there is no configuration path a call site could quietly @@ -1972,15 +2013,45 @@ function resolveFactoryTable(root, source, argument, parseModule) { * * @returns {{ findings: Array, counters: Record, enKeyCount: number, * referencedKeys: Set, referencedBranches: Set, dynamicHeads: Set, - * dynamicFamilies: Map, sites: Array, multiSubstitution: number }> }} + * dynamicFamilies: Map, sites: Array, multiSubstitution: number }>, + * framedReads: Map }} */ export function analyze( root, - /** @type {{ families?: DynamicKeyFamily[], handRolled?: { file: string, name: string }[] }} */ - { families = DYNAMIC_KEY_FAMILIES, handRolled = HAND_ROLLED_TABLES } = {}, + /** @type {{ families?: DynamicKeyFamily[], handRolled?: { file: string, name: string }[], frames?: Record }} */ + { families = DYNAMIC_KEY_FAMILIES, handRolled = HAND_ROLLED_TABLES, frames = PACK_NAMESPACE_FRAMES } = {}, ) { - const { leaves, branches, values } = collectEnKeys(root); + for (const [dir, frame] of Object.entries(frames)) { + if (!Array.isArray(frame) || frame.length === 0 || frame.some((prefix) => typeof prefix !== 'string' || !prefix.endsWith('/'))) { + throw new Error( + `the pack namespace \`${dir}\` declares no frame: list the source directories (each ending in \`/\`) ` + + 'whose files render under the gate that fetches it, in PACK_NAMESPACE_FRAMES', + ); + } + } + const { leaves, branches, values, namespaceOf } = collectEnKeys(root, Object.keys(frames)); const resolvesLeaf = (key) => leaves.has(key) || PLURAL_SUFFIXES.some((suffix) => leaves.has(key + suffix)); + + // Class 9 (objectstack#30645): which namespace a key lives in ONLY, or null + // when the eager pack answers it (or nothing does — class 1's business). + const residentLeaves = [...leaves].filter((key) => !namespaceOf.has(key)); + const namespaceOnlyKey = (key) => { + const forms = [key, ...PLURAL_SUFFIXES.map((suffix) => key + suffix)].filter((form) => leaves.has(form)); + if (forms.length === 0 || forms.some((form) => !namespaceOf.has(form))) return null; + return namespaceOf.get(forms[0]); + }; + const namespaceOnlyHead = (head) => { + if (residentLeaves.some((key) => key.startsWith(head))) return null; + const first = [...namespaceOf.keys()].find((key) => key.startsWith(head)); + return first === undefined ? null : namespaceOf.get(first); + }; + /** Per namespace: the call-site keys read inside its frame — the live control on class 9. */ + const framedReads = new Map(Object.keys(frames).map((dir) => [dir, 0])); + const judgeFrame = (dir, relPath, at, detail) => { + if (dir === null) return; + if (frames[dir].some((prefix) => relPath.startsWith(prefix))) framedReads.set(dir, framedReads.get(dir) + 1); + else findings.push({ reason: 'namespace-key-outside-frame', ...at, detail, expected: dir, frame: frames[dir] }); + }; // 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 测试纪律). @@ -2406,6 +2477,9 @@ export function analyze( referencedKeys.add(key); for (const suffix of PLURAL_SUFFIXES) referencedKeys.add(key + suffix); if (returnsObjects && branches.has(key)) referencedBranches.add(key); + + if (resolvesLeaf(key)) judgeFrame(namespaceOnlyKey(key), relPath, at, key); + else if (returnsObjects && branches.has(key)) judgeFrame(namespaceOnlyHead(`${key}.`), relPath, at, `${key}.*`); } // objectui#3810 — the inline default is dead code the moment the key @@ -2542,6 +2616,8 @@ export function analyze( const head = staticHead(argument); if (head && !headMatches(head)) { findings.push({ reason: 'missing-prefix', ...at, detail: head }); + } else if (head) { + judgeFrame(namespaceOnlyHead(head), relPath, at, `${head}*`); } // Reverse-sweep bookkeeping: a leaf sharing this prefix is a // possible runtime target of the substitution, so it is live — @@ -2652,7 +2728,16 @@ export function analyze( findings.push({ reason: 'undeclared-dynamic-family', ...observed.sites[0], detail: head }); } - return { findings, counters, enKeyCount: leaves.size, referencedKeys, referencedBranches, dynamicHeads, dynamicFamilies }; + return { + findings, + counters, + enKeyCount: leaves.size, + referencedKeys, + referencedBranches, + dynamicHeads, + dynamicFamilies, + framedReads, + }; } // ── baseline ───────────────────────────────────────────────────────────────── @@ -2828,6 +2913,15 @@ const HINTS = { ' downstream of `t()`, is spelled with SINGLE braces (`{x}`) and is out of this rule\'s range' + ' by construction. Same rule, same reasons, over the copy TABLES:' + ' `packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts`.', + 'namespace-key-outside-frame': + 'The key EXISTS, but only in a lazy pack-shaped namespace (PACK_NAMESPACE_FRAMES), and this call' + + ' site is outside the directories that namespace\'s frame wraps (objectstack#30645). Only the frame' + + ' fetches the namespace, so here the key renders raw — or in English, when an earlier visit fetched' + + ' only `en`. Every pack gate is green on it, because the namespace is part of the pack to them. Fix' + + ' it by moving the key back into the resident pack (`packages/i18n/src/locales/.ts`, all ten' + + ' languages, out of the namespace\'s ten partial packs), or by reading it only inside the frame. ⛔' + + ' Do NOT widen the frame to cover this file: a frame is the directories that render under the gate,' + + ' and a file outside them gains no fetch from being listed.', 'dead-sibling-fallback': 'The key EXISTS in `en`, and this fallback is written as the call\'s SIBLING' + ' (`t(key) || \'English\'`) rather than as an argument — so it is dead on every path, not just' + @@ -2850,7 +2944,7 @@ const invokedDirectly = isEntrypoint(import.meta.url); if (invokedDirectly) { const root = resolve(scriptDir, '..'); - const { findings, counters, enKeyCount } = analyze(root); + const { findings, counters, enKeyCount, framedReads } = 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 @@ -2921,6 +3015,20 @@ if (invokedDirectly) { process.exit(1); } + // Class 9's floor (objectstack#30645): a namespace whose own frame reads none + // of its keys means the frame or the namespace reader is wrong, and "0 outside + // the frame" would then be read off a rule that sees no namespace key at all. + for (const [dir, reads] of framedReads) { + if (reads === 0) { + console.error( + `The namespace-frame scan collapsed: no call site inside the frame of \`${dir}\` reads a key only that` + + ' namespace defines. Either PACK_NAMESPACE_FRAMES names the wrong directories or the namespace is no' + + ' longer read, and class 9 would be passing on an empty set.', + ); + process.exit(1); + } + } + const { unexpected, stale } = applyBaseline(findings, readBaseline(root)); console.log( @@ -2970,6 +3078,11 @@ if (invokedDirectly) { `${counters.headlessDynamicKeySites} dynamic call site(s) with no static head at all, ` + `${counters.keyBuilderSites} head(s) read off a key-building helper rather than a call site.`, ); + console.log( + `Namespace frames: ${framedReads.size} pack-shaped namespace(s) — ` + + [...framedReads].map(([dir, reads]) => `${dir} read ${reads} time(s) inside its frame`).join(', ') + + '; a key only a namespace defines is refused anywhere outside that namespace\'s frame.', + ); console.log( `Sibling fallbacks: ${counters.siblingFallbacks} call site(s) sit left of a ||/?? — ` + `${counters.judgedSiblingFallbacks} judged, ${counters.computedSiblingFallbacks} with a non-literal ` + @@ -2987,6 +3100,7 @@ if (invokedDirectly) { const parity = unexpected.filter((finding) => finding.reason === 'interpolation-parity'); const siblings = unexpected.filter((finding) => finding.reason === 'dead-sibling-fallback'); const spelling = unexpected.filter((finding) => finding.reason === 'unresolvable-default-spelling'); + const outsideFrame = unexpected.filter((finding) => finding.reason === 'namespace-key-outside-frame'); // 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. @@ -3008,7 +3122,10 @@ if (invokedDirectly) { 'unresolvable-default-spelling', ]); const keyFindings = unexpected.filter( - (finding) => !VALUE_CLASSES.has(finding.reason) && !FAMILY_CLASSES.has(finding.reason), + (finding) => + !VALUE_CLASSES.has(finding.reason) && + !FAMILY_CLASSES.has(finding.reason) && + finding.reason !== 'namespace-key-outside-frame', ); if (unexpected.length === 0 && stale.length === 0) { @@ -3018,8 +3135,9 @@ if (invokedDirectly) { ' createSafeTranslation defaults row repeats it too, every call site passes' + ' exactly the arguments that value has holes for, every inline defaultValue spells its' + ' placeholders the one way the provider-less fallback resolves, 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.', + ' literal fallback beside itself, every dynamic key family either checks its members' + + ' against a declared vocabulary or says in writing why it has none, and no key only a lazy' + + ' namespace defines is read outside that namespace\'s frame.', ); process.exit(0); } @@ -3128,6 +3246,19 @@ if (invokedDirectly) { } } + if (outsideFrame.length > 0) { + console.error( + `\n${outsideFrame.length} call site${outsideFrame.length === 1 ? '' : 's'} outside a namespace's frame ` + + `read${outsideFrame.length === 1 ? 's' : ''} a key only that lazy namespace defines — nothing fetches ` + + 'the namespace there, so the user reads the raw key (or English, if an earlier visit fetched only `en`):', + ); + for (const finding of outsideFrame) { + console.error(` ${finding.file}:${finding.line}:${finding.column} [${finding.reason}] ${finding.detail}`); + console.error(` defined only in: ${finding.expected}en.ts`); + console.error(` its frame: ${finding.frame.join(', ')}`); + } + } + if (families.length > 0) { console.error( `\n${families.length} dynamic-family finding${families.length === 1 ? '' : 's'} — the head resolves, so the` +