Skip to content

Commit de0f692

Browse files
chore(ui-i18n): the call-site key gate refuses a lazy-namespace key read outside its namespace's frame (#30853)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao4D7g8Jw4AoTpqTHJgB4GVG)_ Fixes #30645 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:i18n 调用点门禁(`check:i18n-keys`)新增第 9 类 `namespace-key-outside-frame`:只存在于懒加载命名空间(目前是 marketplace)里的 key,如果在该命名空间的「框」目录之外被 `t()` 读取,门禁直接变红。 - 为什么改:#30640 把大部分 `marketplace.*` 文案移进了懒加载包,只有两个市场页面会拉取它;别的页面(比如 Studio、Setup)一旦复用其中某个 key,用户会看到原始 key,而原有的各道 i18n 门禁都把命名空间当成常驻包,全部是绿的。 - 风险与回滚:只改门禁脚本和它的测试,不发布任何包、不改运行时;当前树上 0 条新发现。回滚即 revert 本 PR。 - 先看:`ui/scripts/check-i18n-call-site-keys.mjs` 的 `PACK_NAMESPACE_FRAMES`(命名空间与它的框声明在同一张表里)、`analyze()` 里的 `judgeFrame` 三处调用点,以及测试文件里新的 describe。 - 两分钟验证:`pnpm --dir ui check:i18n-keys`,预期退出 0,并打印一行 `Namespace frames: 1 pack-shaped namespace(s) — packages/i18n/src/namespaces/marketplace/ read 183 time(s) inside its frame`。 - 需要你做的:无,席位落地。 ## What changes `ui/scripts/check-i18n-call-site-keys.mjs`: - The one list of pack-shaped namespaces now carries each namespace's frame: `PACK_NAMESPACE_FRAMES` maps the namespace directory to the source directories that render under the gate fetching it (marketplace: `packages/app-shell/src/console/marketplace/`). `PACK_NAMESPACE_DIRS` is derived from its keys, so every existing reader (`collectEnKeys`, `check-i18n-en-drift.mjs`, `check-i18n-dead-keys.mjs`) reads the same list, and a namespace cannot be added without its frame. `analyze()` throws on a namespace declared with an empty frame. - `collectEnKeys` also returns `namespaceOf`: which namespace each namespace-defined leaf lives in. - New class 9, `namespace-key-outside-frame`, HARD from day one (no baseline). A call site outside a namespace's frame is refused when it asks for a key that only that namespace answers: a literal key (plural forms included: refused when every form that resolves is namespace-only), a `returnObjects` subtree, or a template head with no resident leaf under it. A key the resident pack answers is never refused. - A live control: the run prints `Namespace frames:` with how many call-site keys each frame reads, and fails as a collapsed scan when a declared namespace's frame reads none. - Header, `HINTS` entry and its own failure paragraph for the class. Division of labour with `marketplaceNamespaceBoundary.test.ts` (unchanged): this gate judges every file OUTSIDE the frame directories; the boundary test keeps judging the files INSIDE `console/marketplace/` that a page outside the frame imports. ## Tests `ui/scripts/__tests__/check-i18n-call-site-keys.test.ts`, new describe `a key only a namespace defines is refused outside its frame (objectstack#30645)`, 10 cases on synthetic repos and the real tree: - positive control: a namespace-only key spelled in `packages/studio/src/page.tsx` turns the class red, naming the namespace; - the same key inside the frame is silent and counted in `framedReads`; a resident key outside the frame is silent; - plural base, template head (namespace-only vs one a resident leaf answers), `returnObjects` subtree; - a second namespace (`billing`) declared with its own frame is judged with no other change; - a namespace declared with an empty frame throws; - one list read both ways (`PACK_NAMESPACE_DIRS` equals the frame map's keys, every frame non-empty); - on this tree: 0 findings of the class, and every frame reads at least one key. Real-tree probe (one-off, file removed afterwards, not committed): a file `packages/app-shell/src/console/Probe30645.tsx` calling `t('marketplace.title')` made `node scripts/check-i18n-call-site-keys.mjs` exit 1 with `[namespace-key-outside-frame] marketplace.title`; with the file removed it exits 0. ## Acceptance notes - Not judged by the new class, by construction: a key built by a helper or spelled in a table and passed to `t()` as a variable. That is the same blind spot class 1 (`missing-key`) has; the boundary test's literal scan still reads such spellings inside `console/marketplace/`. - A declared dynamic family whose head is resident but some of whose members are namespace-only is not judged member by member; the head is passed because a resident leaf answers it. ## Verification Head `f4a22f9b`, tree `objectstack-ai/objectstack`. <details><summary>Derived gates: 20 of 20 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 20 lines from the 2 changed files. All 20 exited 0, among them `pnpm --dir ui exec vitest run scripts/` (under the verify lock: Test Files 170 passed, 2 skipped; Tests 4989 passed, 2 skipped; `VERDICT command-exit 0`). `dispatch-gates --ran` printed `20 derived famil(ies) accounted for — 20 run, 0 NOT-MEASURED`. Run beyond the derivation, all exit 0 on the final head: `node scripts/check-sdui-manifest.mjs`, `pnpm --dir ui check:i18n-keys`, `pnpm --dir ui check:new-line-citations`, `pnpm --dir ui run type-check:scripts`. The scripts type-check was red once on an earlier head (the new return field and the frame map were untyped in JSDoc) and is green on `f4a22f9b`. </details> <details><summary>Not run locally</summary> `marketplaceNamespaceBoundary.test.ts` is unchanged. Locally it stops at import (`@objectstack/spec/ui` has no dist in this worktree), so it is NOT MEASURED here and Test UI runs it. </details> No published package changes (`ui/scripts/` belongs to the private `object-ui-monorepo` root), hence `skip-changeset`. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxDqYaHT7GBjAY9FzB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2cf269f commit de0f692

2 files changed

Lines changed: 264 additions & 18 deletions

File tree

‎ui/scripts/__tests__/check-i18n-call-site-keys.test.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
holesOf,
1818
PACK_HOOK,
1919
PACK_NAMESPACE_DIRS,
20+
PACK_NAMESPACE_FRAMES,
2021
readVocabulary,
2122
RESERVED_OPTION_NAMES,
2223
unresolvableSpellings,
@@ -302,6 +303,120 @@ describe('the pack-shaped namespaces are part of the en pack (objectstack#30588)
302303
});
303304
});
304305

306+
describe('a key only a namespace defines is refused outside its frame (objectstack#30645)', () => {
307+
const NS_EN = `${PACK_NAMESPACE_DIRS[0]}en.ts`;
308+
const FRAME = PACK_NAMESPACE_FRAMES[PACK_NAMESPACE_DIRS[0]][0];
309+
const NS_PACK =
310+
"const en = { shop: { title: 'Shop', row_one: '{{count}} row', row_other: '{{count}} rows', " +
311+
"status: { open: 'Open', closed: 'Closed' } } } as const;\nexport default en;\n";
312+
const RESIDENT = "const en = { shop: { kept: 'Kept', tag: { a: 'A' } } } as const;\nexport default en;\n";
313+
const page = (body: string) =>
314+
`import { useObjectTranslation } from '${I18N_PKG}';\nexport function P({ s }: { s: string }) { const { t } = useObjectTranslation(); return ${body}; }\n`;
315+
const outsideFrame = (root: string) => findingsOf(root, 'namespace-key-outside-frame');
316+
317+
it('every namespace directory is declared with a non-empty frame (one list, read both ways)', () => {
318+
expect(Object.keys(PACK_NAMESPACE_FRAMES)).toEqual([...PACK_NAMESPACE_DIRS]);
319+
for (const dir of PACK_NAMESPACE_DIRS) expect(PACK_NAMESPACE_FRAMES[dir].length).toBeGreaterThan(0);
320+
});
321+
322+
it('turns red on a namespace-only key spelled outside the frame (the positive control)', () => {
323+
const root = repoWith({
324+
'packages/i18n/src/locales/en.ts': RESIDENT,
325+
[NS_EN]: NS_PACK,
326+
'packages/studio/src/page.tsx': page("t('shop.title')"),
327+
});
328+
// fixture-address: the finding the gate reports for the fixture page written just above
329+
expect(outsideFrame(root)).toEqual(['shop.title@packages/studio/src/page.tsx:2']);
330+
const finding = analyze(root, { families: [] }).findings.find(
331+
(f: { reason: string }) => f.reason === 'namespace-key-outside-frame',
332+
);
333+
expect(finding?.expected).toBe(PACK_NAMESPACE_DIRS[0]);
334+
});
335+
336+
it('reads the same key inside the frame, and counts it there', () => {
337+
const root = repoWith({
338+
'packages/i18n/src/locales/en.ts': RESIDENT,
339+
[NS_EN]: NS_PACK,
340+
[`${FRAME}Page.tsx`]: page("t('shop.title')"),
341+
});
342+
expect(outsideFrame(root)).toEqual([]);
343+
expect(analyze(root, { families: [] }).framedReads.get(PACK_NAMESPACE_DIRS[0])).toBe(1);
344+
});
345+
346+
it('stays silent on a resident key outside the frame', () => {
347+
const root = repoWith({
348+
'packages/i18n/src/locales/en.ts': RESIDENT,
349+
[NS_EN]: NS_PACK,
350+
'packages/studio/src/page.tsx': page("t('shop.kept')"),
351+
});
352+
expect(outsideFrame(root)).toEqual([]);
353+
expect(analyze(root, { families: [] }).framedReads.get(PACK_NAMESPACE_DIRS[0])).toBe(0);
354+
});
355+
356+
it('refuses a plural base whose forms live only in the namespace', () => {
357+
const root = repoWith({
358+
'packages/i18n/src/locales/en.ts': RESIDENT,
359+
[NS_EN]: NS_PACK,
360+
'packages/studio/src/page.tsx': page("t('shop.row', { count: 2 })"),
361+
});
362+
// fixture-address: the finding the gate reports for the fixture page written just above
363+
expect(outsideFrame(root)).toEqual(['shop.row@packages/studio/src/page.tsx:2']);
364+
});
365+
366+
it('refuses a template head only the namespace answers, and passes one a resident leaf answers', () => {
367+
const root = repoWith({
368+
'packages/i18n/src/locales/en.ts': RESIDENT,
369+
[NS_EN]: NS_PACK,
370+
'packages/studio/src/a.tsx': page('t(`shop.status.${s}`)'),
371+
'packages/studio/src/b.tsx': page('t(`shop.tag.${s}`)'),
372+
});
373+
// fixture-address: the finding the gate reports for the fixture page written just above
374+
expect(outsideFrame(root)).toEqual(['shop.status.*@packages/studio/src/a.tsx:2']);
375+
});
376+
377+
it('refuses a returnObjects subtree that only the namespace fills', () => {
378+
const root = repoWith({
379+
'packages/i18n/src/locales/en.ts': RESIDENT,
380+
[NS_EN]: NS_PACK,
381+
'packages/studio/src/page.tsx': page("t('shop.status', { returnObjects: true })"),
382+
});
383+
// fixture-address: the finding the gate reports for the fixture page written just above
384+
expect(outsideFrame(root)).toEqual(['shop.status.*@packages/studio/src/page.tsx:2']);
385+
});
386+
387+
it('a second namespace inherits the rule from its declaration alone', () => {
388+
const SECOND = 'packages/i18n/src/namespaces/billing/';
389+
const frames = { ...PACK_NAMESPACE_FRAMES, [SECOND]: ['packages/app-shell/src/console/billing/'] };
390+
const root = repoWith({
391+
'packages/i18n/src/locales/en.ts': RESIDENT,
392+
[`${SECOND}en.ts`]: "const en = { billing: { title: 'Billing' } } as const;\nexport default en;\n",
393+
'packages/app-shell/src/console/billing/Page.tsx': page("t('billing.title')"),
394+
'packages/studio/src/page.tsx': page("t('billing.title')"),
395+
});
396+
const result = analyze(root, { families: [], frames });
397+
const refused = result.findings
398+
.filter((f: { reason: string }) => f.reason === 'namespace-key-outside-frame')
399+
.map((f: { detail: string; file: string; expected: string }) => `${f.detail}@${f.file} <- ${f.expected}`);
400+
expect(refused).toEqual([`billing.title@packages/studio/src/page.tsx <- ${SECOND}`]);
401+
expect(result.framedReads.get(SECOND)).toBe(1);
402+
});
403+
404+
it('refuses a namespace declared without a frame', () => {
405+
const root = repoWith({ 'packages/i18n/src/locales/en.ts': RESIDENT });
406+
expect(() => analyze(root, { families: [], frames: { 'packages/i18n/src/namespaces/billing/': [] } })).toThrow(
407+
/declares no frame/,
408+
);
409+
});
410+
411+
it('on this tree: no namespace key is read outside its frame, and every frame reads some', () => {
412+
const outside = REPO_ANALYSIS.findings.filter(
413+
(f: { reason: string }) => f.reason === 'namespace-key-outside-frame',
414+
);
415+
expect(outside).toEqual([]);
416+
for (const dir of PACK_NAMESPACE_DIRS) expect(REPO_ANALYSIS.framedReads.get(dir)).toBeGreaterThan(0);
417+
});
418+
});
419+
305420
describe('the file walk', () => {
306421
it('reads sources but not tests, type declarations or build output', () => {
307422
const root = repoWith({

0 commit comments

Comments
 (0)