Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
---

Teach `check-i18n-dead-keys` to follow a key template assigned to a local variable
ONE HOP before `t()`, and pin the keys that leg makes reachable (objectui#8754,
round 1 of the 2026-09-12 ruling — the instrument round; nothing is deleted here).

Test and tooling only; no package is released by this change.

**The shape.** A component builds the key into a `const` and passes the bare
identifier: `` const k = `ns.family${Cap(kind)}s` `` then `t(k)`. Every leg was
blind to it at once — the argument position sees an identifier rather than a
template, the objectui#7592 key-builder leg needs a function whose whole body is
one returned template, the property-chain leg does not apply below three
segments, and the text safety net sees only the head, which
`occursAtKeyBoundary()` correctly refuses as evidence about a longer key. So
every leaf under the head landed in CONFIRMED, the tier documented as the one to
read first, while a shipping screen rendered it.

**Measured.** The census moves 365 → 357 candidates and **127 → 119 CONFIRMED**;
NEEDS-REVIEW is unchanged at 238 and no key joined either tier. The leg collects
three heads over nine resolved hops, across 322 parsed files of 1605 walked.

**The pin.** `scripts/__tests__/check-i18n-dead-keys.test.ts` now reds when those
keys leave the packs. It spells no pack key: the heads are read off the call
site's own source and the discriminator off its own closed union, and what is
asserted is the cross product's cardinality — so the pin cannot become a textual
hit that pushes the keys it protects back into NEEDS-REVIEW.

**Bounded on purpose.** The leg feeds reachability only; it does not enrol its
heads in the call-site gate's dynamic-family registry, so `check:i18n-keys` gains
no `undeclared-dynamic-family` finding and its behaviour is unchanged. The two
further sub-shapes objectui#7844 records — a template in a same-module resolver's
ARGUMENT, and a template as an ELEMENT of a returned array — stay dark, and are
now written into the script's own "What CONFIRMED does NOT guarantee" class 2
rather than left silent.
295 changes: 294 additions & 1 deletion scripts/__tests__/check-i18n-dead-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ANALYSED_PACK_OBJECT_IMPORTERS,
DESIGNER_TABLE,
collectDesignerKeys,
collectIndirectTemplateHeads,
derivePackObjectImporters,
derivePackObjectKeyReads,
packObjectReadsNoLegSees,
Expand All @@ -16,7 +17,7 @@ import {
sweepDesignerTable,
textFootprint,
} from '../check-i18n-dead-keys.mjs';
import { collectEnKeys } from '../check-i18n-call-site-keys.mjs';
import { analyze, collectEnKeys, readVocabulary } from '../check-i18n-call-site-keys.mjs';

/**
* objectui#4658 — the behaviour test for `scripts/check-i18n-dead-keys.mjs`,
Expand Down Expand Up @@ -1452,3 +1453,295 @@ describe('the pack-object property reads are derived, with their depth (objectui
});
});
});

/**
* objectui#8754 — the ONE-HOP INDIRECT TEMPLATE LEG, and the PIN for the keys
* it makes reachable.
*
* ## Why this block exists, and what it is answerable to
*
* The shape: a component builds the key into a local and passes the bare
* identifier to `t()`. Before the leg, every leaf under such a head landed in
* CONFIRMED — the top tier — while a shipping screen rendered it. Measured on
* `main` when the card was ruled: eight of them, deleted from all ten packs,
* `1090/1090` i18n tests green, the owning view's own suite green, and this
* script reporting the shorter tier without complaint. ⇒ ⛔ a green CI was
* worth NOTHING on this class, and that — not the eight keys — is what the pin
* below is for.
*
* ## ⛔ How the pin avoids the self-pollution trap
*
* `textFootprint()` greps the whole repo, `scripts/` included. A test that
* SPELLS a pack key becomes a bounded textual occurrence of it and pushes that
* key from CONFIRMED to NEEDS-REVIEW — the trap this script's own header
* records from an earlier draft, and the one objectui#8754 names in its "What
* NOT to do". So ⛔ NOT ONE PACK KEY IS SPELLED HERE. Everything is DERIVED
* from the tree on the run that reads it:
*
* - the HEADS come out of `collectIndirectTemplateHeads()`, read off the
* view's own source;
* - the MEMBERS come out of `readVocabulary()` over the view's own closed
* discriminator union — the same reader `DYNAMIC_KEY_FAMILIES` uses;
* - the KEYS are never written down at all: what is asserted is the cross
* product's CARDINALITY against the `en` pack.
*
* The one thing spelled is the call site's PATH and the union's name, and
* neither is a key. Measured on the run that introduced this block: the
* NEEDS-REVIEW set is byte-identical before and after it, and no key joined
* CONFIRMED.
*
* ## The failure it actually catches
*
* Delete any of those keys from the packs and the head stops resolving against
* `en`, so the leg's boundary 2 stops recognising the site and the head
* disappears from the report — the leg degrading to a no-op exactly when it
* matters. That is why the first assertion is a FLOOR on the head count and not
* a property of the keys: it reds on the deletion through the leg's own
* blindness, and it reds again on the cardinality.
*/
describe('the one-hop indirect template leg (objectui#8754)', () => {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');

/**
* The contract one collected head states, as a function so the guards can be
* shown to THROW rather than asserted to hold — the obligation PR
* objectui#8753 met the same way, inside the harness instead of in a file the
* census reads.
*
* A head plus a closed discriminator is a cross product, and every member of
* it has to exist in the pack. Both directions are defects: a member with no
* key renders the raw key name, a key with no member is a leaf nothing can
* reach.
*/
function headCoversDiscriminator(head: string, members: readonly string[], leaves: Set<string>): string[] {
if (members.length === 0) {
throw new Error(`the discriminator for ${head} is empty — a cross product of nothing checks nothing`);
}
const under = [...leaves].filter((key) => key.startsWith(head)).sort();
if (under.length !== members.length) {
throw new Error(
`${head} is reached with ${members.length} discriminator member(s) but the en pack holds ` +
`${under.length} leaf/leaves under it`,
);
}
return under;
}

describe('its guards throw on an emptied set', () => {
// The anti-vacuity half. A guard that cannot fail is not a guard, and the
// way to know is to make it fail on purpose — here, rather than by trusting
// that the real-tree call below would have.
it('throws when the discriminator is empty', () => {
expect(() => headCoversDiscriminator('fixture.head', [], new Set(['fixture.headA']))).toThrow(
/discriminator .* is empty/,
);
});

it('throws when the pack holds fewer leaves than the discriminator has members', () => {
expect(() =>
headCoversDiscriminator('fixture.head', ['a', 'b'], new Set(['fixture.headA'])),
).toThrow(/2 discriminator member\(s\) but the en pack holds 1/);
});

it('throws when the pack holds none at all — the deletion this pins', () => {
expect(() => headCoversDiscriminator('fixture.head', ['a'], new Set())).toThrow(/holds 0 leaf/);
});

it('returns the leaves when the cross product is whole', () => {
expect(headCoversDiscriminator('fixture.head', ['a', 'b'], new Set(['fixture.headA', 'fixture.headB']))).toEqual([
'fixture.headA',
'fixture.headB',
]);
});
});

describe('on the real tree', () => {
/** The call site, by PATH — not by any key it builds. */
const HOP_SITE = 'packages/app-shell/src/views/SearchResultsPage.tsx';
/** Its closed discriminator, read with the registry's own vocabulary reader. */
const HOP_DISCRIMINATOR = {
module: HOP_SITE,
name: 'SearchResult',
kind: 'interfaceField' as const,
field: 'type',
};

const packKeys = collectEnKeys(repoRoot);
const collected = collectIndirectTemplateHeads(repoRoot, packKeys);
const headsAtHopSite = [...collected.heads]
.filter(([, sites]) => sites.some((site) => site.file === HOP_SITE))
.map(([head]) => head)
.sort();

it('does not collapse — a leg that finds nothing reports as a tree with no such shape', () => {
expect(collected.counters.filesParsed, 'zero parsed files means the pre-filter or the walk broke').toBeGreaterThan(
0,
);
expect(collected.heads.size, 'zero heads is the no-op this leg exists to stop being').toBeGreaterThan(0);
});

it('follows the assignment hop at the site that hid the keys', () => {
// The FLOOR, and the half that reds on the deletion: remove the keys and
// the head stops resolving against `en`, so the site stops being seen at
// all. Stated as a count over the site rather than as a head spelling,
// because the head is a prefix of the keys and this file must not carry
// one.
expect(
headsAtHopSite.length,
`${HOP_SITE} builds its keys one hop before t(); a leg that sees none of them is blind again`,
).toBe(2);
});

it('every head it collects holds the whole cross product of its discriminator', () => {
const members = readVocabulary(repoRoot, HOP_DISCRIMINATOR);
expect(members, `${HOP_DISCRIMINATOR.name}.${HOP_DISCRIMINATOR.field} is no longer a readable union`).not.toBeNull();
for (const head of headsAtHopSite) {
expect(() => headCoversDiscriminator(head, members as string[], packKeys.leaves)).not.toThrow();
}
});

it('keeps every key it makes reachable OUT of both tiers', () => {
// The end-to-end reading, and the one a reviewer can check against the
// CLI: a key under a collected head must be neither CONFIRMED nor
// NEEDS-REVIEW, because it is not a candidate at all.
const result = sweep(repoRoot);
const reachable = [...packKeys.leaves].filter((key) => [...collected.heads.keys()].some((head) => key.startsWith(head)));
expect(reachable.length, 'the heads hold no leaves — the cross-check would be vacuous').toBeGreaterThan(0);
const stillCandidate = reachable.filter(
(key) => result.confirmed.includes(key) || result.needsReview.some((entry) => entry.key === key),
);
expect(stillCandidate, 'a key a collected head reaches is not a candidate').toEqual([]);
});

it('⛔ does NOT enrol its heads in the call-site gate’s family registry', () => {
// The deliberate asymmetry, pinned so that wiring this leg into the gate
// is a conscious act rather than a side effect. `dynamicFamilies` drives
// `undeclared-dynamic-family`, a RED finding; objectui#7592 measured that
// widening and objectui#7844 calls the registry entry a decision with its
// own blast radius. This leg buys reachability and stops.
const gate = analyze(repoRoot);
for (const head of collected.heads.keys()) {
if (gate.dynamicHeads.has(head)) continue; // already a head by its own argument position
expect(
gate.dynamicFamilies.has(head),
`${head} reached the gate's family census through the reverse sweep's leg`,
).toBe(false);
}
expect(
gate.findings.filter((finding: { reason: string }) => finding.reason === 'undeclared-dynamic-family'),
'the leg must not add an undeclared family to the gate',
).toEqual([]);
});

it('⛔ leaves objectui#7844’s two sub-shapes DARK, and says so', () => {
// The declared gap. A silent one is the defect objectui#8754 is about; a
// declared one is this repo's accepted state. Both files are named by
// PATH and their heads are read off the tree, never spelled here.
const contributing = new Set([...collected.heads.values()].flat().map((site) => site.file));
expect(contributing.has('apps/console/src/pages/settings/useSettingsLabel.ts')).toBe(false);
expect(contributing.has('packages/i18n/src/useObjectLabel.ts')).toBe(false);
// …and the header must keep saying so. A gap that stops being written
// down is a gap again.
const header = fs.readFileSync(path.join(repoRoot, 'scripts', 'check-i18n-dead-keys.mjs'), 'utf8');
expect(header).toContain('apps/console/src/pages/settings/useSettingsLabel.ts');
expect(header).toContain('packages/i18n/src/useObjectLabel.ts');
});
});

describe('the three boundaries, on synthetic repos', () => {
/** A pack with one family reachable only through the assignment hop, plus
* one leaf nothing reaches at all. */
const HOP_EN = `const en = {
ns: { familyOne: 'One', familyTwo: 'Two', lonely: 'Nobody' },
} as const;
export default en;
`;

it('takes a key out of the candidate set when the template is assigned one hop before t()', () => {
const root = repoWith({
'packages/i18n/src/locales/en.ts': HOP_EN,
'packages/app-shell/src/Hop.tsx': `
import { useObjectTranslation } from '${I18N_PKG}';
export function Hop({ kind }: { kind: 'One' | 'Two' }) {
const { t } = useObjectTranslation();
const key = \`ns.family\${kind}\`;
return t(key);
}
`,
});
const result = sweep(root);
expect(result.confirmed, 'the hop keys are reachable; only the lonely leaf is a candidate').toEqual(['ns.lonely']);
});

it('does NOT resolve a bare identifier that is not a same-file template', () => {
// Boundary 1, in the direction that matters: recall must not become "any
// identifier". A key composed elsewhere stays a candidate, and the text
// net is what is supposed to catch it.
const root = repoWith({
'packages/i18n/src/locales/en.ts': HOP_EN,
'packages/app-shell/src/Elsewhere.tsx': `
import { useObjectTranslation } from '${I18N_PKG}';
export function Elsewhere({ key }: { key: string }) {
const { t } = useObjectTranslation();
return t(key);
}
`,
});
const { heads } = collectIndirectTemplateHeads(root);
expect([...heads.keys()]).toEqual([]);
expect(sweep(root).confirmed.sort()).toEqual(['ns.familyOne', 'ns.familyTwo', 'ns.lonely']);
});

it('does NOT collect a head that resolves against nothing in the pack', () => {
// Boundary 2. Without it this is a census of every templated local in the
// repo rather than a key probe.
const root = repoWith({
'packages/i18n/src/locales/en.ts': HOP_EN,
'packages/app-shell/src/NotAKey.tsx': `
import { useObjectTranslation } from '${I18N_PKG}';
export function NotAKey({ v }: { v: string }) {
const { t } = useObjectTranslation();
const cssVar = \`--brand-token-\${v}\`;
return t(cssVar);
}
`,
});
expect([...collectIndirectTemplateHeads(root).heads.keys()]).toEqual([]);
});

it('does NOT collect a template whose head is empty', () => {
// The `${ns}.rest` shape: there is no static head, so there is nothing to
// be a prefix of, and treating `''` as a head marks the whole pack live.
const root = repoWith({
'packages/i18n/src/locales/en.ts': HOP_EN,
'packages/app-shell/src/Headless.tsx': `
import { useObjectTranslation } from '${I18N_PKG}';
export function Headless({ ns, leaf }: { ns: string; leaf: string }) {
const { t } = useObjectTranslation();
const key = \`\${ns}.family\${leaf}\`;
return t(key);
}
`,
});
expect([...collectIndirectTemplateHeads(root).heads.keys()]).toEqual([]);
expect(sweep(root).confirmed.sort()).toEqual(['ns.familyOne', 'ns.familyTwo', 'ns.lonely']);
});

it('skips the registered module-local translator tables', () => {
// Boundary 3, the same scope rule the call-site classifier and the
// key-builder leg use: a builder inside the designer tree builds keys no
// pack defines, by design.
const root = repoWith({
'packages/i18n/src/locales/en.ts': HOP_EN,
'packages/app-shell/src/views/metadata-admin/i18n.ts': `
export function local({ kind }: { kind: string }) {
const t = (k: string) => k;
const key = \`ns.family\${kind}\`;
return t(key);
}
`,
});
expect([...collectIndirectTemplateHeads(root).heads.keys()]).toEqual([]);
});
});
});
Loading
Loading