From ae75edbc430fb5fa7543ccb06c360f86485e8286 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:05:20 +0000 Subject: [PATCH] fix(scripts): follow a key template assigned one hop before t() in the dead-key sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-dead-keys` had no leg for a key built into a local variable and passed to `t()` as a bare identifier. Every other leg is blind to that shape at once, so every leaf under the head landed in CONFIRMED while a shipping screen rendered it — measured, eight keys, whose deletion from all ten packs turned nothing red. `collectIndirectTemplateHeads()` resolves that one hop, same file, with the same two boundaries objectui#7592 put on the key-builder leg: the head must resolve against `en`, and the registered module-local tables are skipped. It feeds reachability only — never the call-site gate's family registry, whose undeclared branch is a red finding — and the asymmetry is pinned. The pin derives the heads from the call site's own source and the members from its own closed union, so it spells no pack key and cannot self-pollute the tiers it protects. Round 1 of objectui#8754 only. No pack key is deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- ...dead-keys-one-hop-indirect-template-leg.md | 36 +++ .../__tests__/check-i18n-dead-keys.test.ts | 295 ++++++++++++++++- scripts/check-i18n-dead-keys.mjs | 299 +++++++++++++++++- 3 files changed, 625 insertions(+), 5 deletions(-) create mode 100644 .changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md diff --git a/.changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md b/.changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md new file mode 100644 index 0000000000..78aa4989aa --- /dev/null +++ b/.changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md @@ -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. diff --git a/scripts/__tests__/check-i18n-dead-keys.test.ts b/scripts/__tests__/check-i18n-dead-keys.test.ts index f4cc203455..f5aa01bb3a 100644 --- a/scripts/__tests__/check-i18n-dead-keys.test.ts +++ b/scripts/__tests__/check-i18n-dead-keys.test.ts @@ -8,6 +8,7 @@ import { ANALYSED_PACK_OBJECT_IMPORTERS, DESIGNER_TABLE, collectDesignerKeys, + collectIndirectTemplateHeads, derivePackObjectImporters, derivePackObjectKeyReads, packObjectReadsNoLegSees, @@ -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`, @@ -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[] { + 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([]); + }); + }); +}); diff --git a/scripts/check-i18n-dead-keys.mjs b/scripts/check-i18n-dead-keys.mjs index 141f7d1384..566b012b8a 100644 --- a/scripts/check-i18n-dead-keys.mjs +++ b/scripts/check-i18n-dead-keys.mjs @@ -63,7 +63,14 @@ * at all, therefore invisible to every leg here at once) now marks its * family live. See that gate's header, "Key-building helpers". * - * A key not covered by any of the three is a CANDIDATE. Two things this + * A FOURTH source of heads was added by objectui#8754 and does not come out of + * `analyze()` at all: `collectIndirectTemplateHeads()` in THIS file reads a + * template assigned to a local ONE HOP before `t()` — the shape that put eight + * keys a shipping screen renders into CONFIRMED. It feeds reachability only, + * never the call-site gate's family registry; its own docstring says why, and + * what it still cannot see. + * + * A key not covered by any of the four is a CANDIDATE. Two things this * mechanism does NOT see, both false-positive sources by construction, both * closed by the text safety net below rather than by widening the AST walk: * @@ -337,6 +344,32 @@ * by a builder shape the key-builder leg does not read: anything but one * returned template literal, and anything that composes the head across * modules. + * ⇒ NARROWED since objectui#8754, and the narrowing is one sub-shape, not + * the class. The one-hop ASSIGNMENT — a template built into a local and + * handed to `t()` as a bare identifier — is now read by + * `collectIndirectTemplateHeads()` above. It was this class's costliest + * instance in the tree: eight keys a shipping screen renders, in CONFIRMED, + * whose deletion from all ten packs turned nothing red (measured, + * objectui#8754). + * ⛔ The class is NOT closed, and objectui#7844 states the real boundary: + * 「the boundary is not 「resolver」 or 「array」; it is that the key + * expression is not at the call site」. Two sub-shapes of it are on `main` + * today and are DARK to every leg here, measured after the narrowing + * landed rather than assumed: + * - a template passed as an ARGUMENT to a same-module resolver — + * `apps/console/src/pages/settings/useSettingsLabel.ts`, head + * `actions.`. Not an assignment, so the hop above does not reach it. + * - a template as one ELEMENT of a returned ARRAY of candidate keys — + * `packages/i18n/src/useObjectLabel.ts`, head `fields.`. Same. + * Neither head is collected by any leg in this file; today neither holds a + * CONFIRMED key, so nothing is at RISK through them — but that is a fact + * about what else happens to spell those keys, not a guarantee, and it is + * the same "by luck, not by design" state class 1 records. Widening to + * reach them is objectui#7844's subject, and objectui#7592 measured why it + * is a separate decision: "any template literal whose head resolves" + * matches 28 heads repo-wide, 25 already declared, and fires + * `undeclared-dynamic-family` — a RED gate — on both of these at once, one + * of them inside the excluded metadata-admin tree. * 3. A consumer outside the AST walk's `packages/`+`apps/` scope that also * never spells the key as text (gap 1 above). * 4. A pack-object property reader of a TWO-SEGMENT key (objectui#8701). The @@ -416,7 +449,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { analyze, collectEnKeys, collectSourceFiles } from './check-i18n-call-site-keys.mjs'; +import { EXCLUDED_TRANSLATORS, analyze, collectEnKeys, collectSourceFiles } from './check-i18n-call-site-keys.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -1324,6 +1357,209 @@ function namespaceOf(key) { return parts.length >= 3 ? parts.slice(0, 2).join('.') : parts[0]; } +/** The translator-call pre-filter, spelled exactly as the call-site gate's own + * `hasTranslatorCall` is (`?\.` included, objectui#4117: a file whose `t` is an + * optional prop holds no `t(` at all). It is the ONLY pre-filter this leg + * uses — see `collectIndirectTemplateHeads()` for why `.${` is not. */ +const TRANSLATOR_CALL_TEXT = /\btt?\s*(?:\?\.)?\s*\(/; + +/** `(x)`, `x as T`, `x!` — the wrappers a key argument or a template + * initializer can carry without changing what it is. Same unwrapping the + * call-site gate does before reading a head, and the reason it does: the cast + * is there to satisfy a key type. */ +function unwrapKeyExpression(node) { + let current = node; + while ( + current && + (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current)) + ) { + current = current.expression; + } + return current ?? null; +} + +/** + * The ONE-HOP INDIRECT TEMPLATE LEG (objectui#8754). + * + * ## The shape, and why every other leg is blind to it at once + * + * A component builds the key into a LOCAL and passes the bare identifier: + * + * const someKey = `ns.family${Cap(kind)}s`; + * // … + * t(someKey) + * + * Nothing here is exotic, and nothing here is visible: + * + * - the ARGUMENT position sees an identifier, not a template, so `staticHead()` + * returns `''` and the site is counted as a headless dynamic key — gap 2 of + * this header, and class 2 of "What CONFIRMED does NOT guarantee"; + * - the KEY-BUILDER leg (objectui#7592) needs a function whose whole body is + * one returned template. A `const` in the middle of a render is not one; + * - the PROPERTY-CHAIN leg needs three segments, and this shape's keys are + * routinely two — class 4; + * - the TEXT safety net sees only the HEAD spelled in that file, and + * `occursAtKeyBoundary()` correctly refuses a prefix as evidence about a + * longer key. That refusal is right, and it is what leaves the tier silent. + * + * ⇒ every leaf under the head lands in CONFIRMED — the top tier — while a + * shipping screen renders it. Measured on this tree when objectui#8754 was + * ruled: eight such keys sat in CONFIRMED, deleting them from all ten packs + * turned NOTHING red, and this script reported the shorter list without + * complaint. That is the measurement this leg exists to answer. + * + * ## Why the pre-filter is NOT the key-builder leg's `.${` + * + * objectui#7592's leg pre-filters on `.${`, "the adjacency a dotted key + * template always has". A KEY FAMILY head always ends at a segment boundary, so + * that holds for a family. It does NOT hold for this shape, whose head ends + * MID-SEGMENT (`ns.family` + a capitalised discriminator + a literal suffix). + * Measured on the file that hid the eight: `grep -c '\.\${'` returns 0 for the + * whole file — the key-builder pre-filter would not have parsed it even if the + * shape had matched. So this leg pre-filters on the translator call alone, and + * pays for it in parses: 322 files of the 1572 walked, against 51 for `.${`. + * + * ## The three boundaries, each load-bearing + * + * 1. ONE HOP, and the declaration must be in the SAME FILE. A key composed + * across modules needs a type checker, not a syntax walk, and inventing a + * head from a name resolved by guess is how a leg starts marking whole + * namespaces live (`wideHeads`, in another costume). + * 2. The head must RESOLVE against `en`, the same test objectui#7592's + * boundary 2 applies for the same reason: without it this is a census of + * every templated local in the repo, not a key probe. The consequence is + * the same too, and it is why the PIN in + * `scripts/__tests__/check-i18n-dead-keys.test.ts` exists: delete the keys + * and the head stops resolving, so the leg stops seeing the site — a + * detection leg that degrades to a no-op exactly when it is needed. The + * leg cannot notice that about itself; a test that knows the site can. + * 3. The registered module-local tables are skipped, the same scope rule the + * call-site classifier and the key-builder leg use. + * + * ## What it deliberately does NOT do: feed `dynamicFamilies` + * + * `analyze()`'s argument position records a template head in TWO places — + * `dynamicHeads` (reachability, which is all this reverse sweep consumes) and + * `dynamicFamilies` (the registry census, whose undeclared branch raises + * `undeclared-dynamic-family`, a RED finding of `check:i18n-keys`). This leg + * feeds the FIRST only, and lives in this file rather than in the gate for that + * reason: it buys reachability for a deletion sweep, and buying it must not + * enrol two new heads in a registry. objectui#7592 measured the cost of the + * wider version (28 heads repo-wide, 25 already declared, both new ones firing + * the red) and objectui#7844 calls the entry itself "a registry decision with + * its own blast radius". Whether these heads become declared families is that + * decision, and it is not this leg's to take. + * + * ⚠️ The consequence is worth saying plainly rather than leaving to the + * asymmetry: a head this leg collects is NOT member-checked. `missing-member` + * never fires for it, so a fifth discriminator that no pack defines is still + * invisible. What this leg buys is that the four that DO exist stop reading as + * dead. + * + * ## What it still does not see — the class is NOT closed (objectui#7844) + * + * The boundary is not "variable", and objectui#7844 states it better than any + * enumeration: the key expression is simply not at the call site. Two further + * sub-shapes of that same class are on `main` today and stay dark after this + * leg — measured, not assumed, and listed in class 2 below: + * + * - a template passed as an ARGUMENT to a same-module resolver + * (`apps/console/src/pages/settings/useSettingsLabel.ts`); + * - a template as one ELEMENT of a returned ARRAY of candidate keys + * (`packages/i18n/src/useObjectLabel.ts`). + * + * Both are one hop too, and neither hop is an assignment. Widening to cover + * them is objectui#7844's subject and fires the red gate above on two heads at + * once; this leg is bounded to the assignment hop on purpose. + * + * @param {string} root + * @param {{ leaves: Set, branches: Set }} [packKeys] + * @returns {{ heads: Map>, + * counters: { filesWalked: number, filesParsed: number, bareIdentifierArguments: number, + * resolvedOneHop: number, headsRecorded: number } }} + */ +export function collectIndirectTemplateHeads(root, packKeys = collectEnKeys(root)) { + const everyPath = [...packKeys.leaves, ...packKeys.branches]; + const headMatches = (head) => everyPath.some((key) => key.startsWith(head)); + const registeredModules = new Set(EXCLUDED_TRANSLATORS.map((entry) => entry.module)); + + /** @type {Map>} */ + const heads = new Map(); + const counters = { + filesWalked: 0, + filesParsed: 0, + bareIdentifierArguments: 0, + resolvedOneHop: 0, + headsRecorded: 0, + }; + + for (const file of collectSourceFiles(root)) { + counters.filesWalked += 1; + const rel = relative(root, file).split('\\').join('/'); + if (registeredModules.has(rel)) continue; + const text = readFileSync(file, 'utf8'); + if (!TRANSLATOR_CALL_TEXT.test(text)) continue; + counters.filesParsed += 1; + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + // Pass one: every `const|let|var =