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 =