From d1ce8e9f85b6ec859d06db73b135078fd8886ce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:28:08 +0000 Subject: [PATCH] fix(scripts): report every dynamic head the i18n dead-key pack sweep applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-dead-keys.mjs` sweeps two corpora with the same mirror question, and the two halves disagree about how wide a template head may be. The designer half refuses a head with no segment of its own, drops it from the applied set, and prints a loud `Dynamic head(s) with no segment of their own, NOT applied`. The pack half applies every collected head unfiltered — and reported nothing about it, so a key held live merely by starting with some head was indistinguishable from a key nothing reaches. The cost is the one objectui#9126 names: an under-reporting sweep reads as evidence of cleanliness. Today's run prints 358 candidates across 46 namespaces, and a reader has no way to learn that 375 further keys were never offered, that 28 heads account for them, or that three heads naming only a top-level namespace hold 196 of those on their own. So `sweep()` now returns one row per APPLIED head — the head, how many segments it names of its own, every `en` leaf under it, and how many of those no other leg already keeps live — and the CLI prints the table plus a line naming the namespace-wide heads. `--json` carries the same rows as `appliedDynamicHeads`. ⛔ Reporting only. The rows are computed AFTER `candidates`, from the same unfiltered head list, and nothing feeds back: the candidate set is byte-identical before and after (358, same `confirmed`/`needsReview` lists, same designer-table output). No filtering is added at any depth and the designer half's `MIN_HEAD_SEGMENTS` is deliberately NOT adopted for the packs — their namespaces are shaped differently and that number is the maintainer's call. No key, tier or retirement is touched, and no claim is made that any of the three heads is wrong: each comes from a real call site building a key from a runtime value. The row measures the head's reach, not its correctness. Tests pin both directions: the rows themselves on a synthetic pack, and the negative control that the namespace-wide head is still applied — plus repository-scale pins that every applied head gets a row, that every row describes a subtraction that really happened, and that the threshold stays unadopted. Part of #9126 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .changeset/9126-pack-dynamic-head-report.md | 4 + .../__tests__/check-i18n-dead-keys.test.ts | 143 ++++++++++++++++++ scripts/check-i18n-dead-keys.mjs | 95 ++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 .changeset/9126-pack-dynamic-head-report.md diff --git a/.changeset/9126-pack-dynamic-head-report.md b/.changeset/9126-pack-dynamic-head-report.md new file mode 100644 index 0000000000..069f52b46b --- /dev/null +++ b/.changeset/9126-pack-dynamic-head-report.md @@ -0,0 +1,4 @@ +--- +--- + +Report-only, `scripts/`-only: `check-i18n-dead-keys.mjs`'s pack half now prints every dynamic template head it applied, how many `en` leaves fall under each, and how many of those that head alone keeps out of the candidate list. No verdict, tier or candidate changes — the sibling designer corpus already reports the same reading, and this closes the asymmetry without adopting its threshold (objectui#9126). diff --git a/scripts/__tests__/check-i18n-dead-keys.test.ts b/scripts/__tests__/check-i18n-dead-keys.test.ts index 0e365cb862..57ad04d184 100644 --- a/scripts/__tests__/check-i18n-dead-keys.test.ts +++ b/scripts/__tests__/check-i18n-dead-keys.test.ts @@ -1806,3 +1806,146 @@ describe('the text sweep does not descend into `.objectui-tmp` (objectui#9201)', expect(String(thrown?.stderr ?? '')).toContain('No such file or directory'); }); }); + +/** + * objectui#9126 — the pack half applies every collected dynamic head unfiltered + * and, until this leg, said NOTHING about which heads those were or how far + * each reached. The whole deliverable is that reading: `sweep()` now returns + * one row per applied head, and the CLI prints them. + * + * ⛔ The card's fences are what these tests are for, and the sharpest one is + * NEGATIVE: reporting must not become filtering. Every test below that measures + * a row is paired with one measuring that the candidate list did not move — + * a row is a description of a subtraction that already happened, never a new + * one. The threshold question (`MIN_HEAD_SEGMENTS`, which the DESIGNER half + * applies to its own corpus) is deliberately not answered here; it is a + * maintainer's call and no test here may smuggle one in. + */ +describe('the applied dynamic heads are REPORTED, not filtered (objectui#9126)', () => { + /** A pack with one family under a head that names a top-level namespace and + * nothing else, and one under a head that names a segment of its own. */ + const HEAD_REPORT_EN = `const en = { + wideNs: { alpha: 'A', beta: 'B', gamma: 'C' }, + narrowNs: { group: { one: 'One', two: 'Two' } }, +} as const; +export default en; +`; + + /** Builds a key under each head from a runtime value, and ALSO asks for one + * leaf under the wide head by its literal name — so `leavesUnder` and + * `heldLive` are different numbers for that row and the difference has a + * cause a reader can point at. */ + const HEAD_REPORT_CONSUMER = ` +import { useObjectTranslation } from '${I18N_PKG}'; +export function Widget({ id }: { id: string }) { + const { t } = useObjectTranslation(); + return [t('wideNs.alpha'), t(\`wideNs.\${id}\`), t(\`narrowNs.group.\${id}\`)]; +} +`; + + function headReportRoot() { + return repoWith({ + 'packages/i18n/src/locales/en.ts': HEAD_REPORT_EN, + 'packages/x/src/Widget.tsx': HEAD_REPORT_CONSUMER, + }); + } + + const rowFor = (root: string, head: string) => sweep(root).appliedHeads.find((row) => row.head === head); + + it('reports every head it applied, including one naming only a top-level namespace', () => { + const heads = sweep(headReportRoot()).appliedHeads.map((row) => row.head); + expect(heads.sort()).toEqual(['narrowNs.group.', 'wideNs.']); + }); + + it('counts the head’s own segments, so a namespace-wide head is visible AS one', () => { + const root = headReportRoot(); + // The reading the card asks for: `wideNs.` names a top-level namespace and + // no more. Reported as `1` — and still applied, which the next test pins. + expect(rowFor(root, 'wideNs.')?.ownSegments).toBe(1); + expect(rowFor(root, 'narrowNs.group.')?.ownSegments).toBe(2); + }); + + it('⛔ NEGATIVE CONTROL: the namespace-wide head is still APPLIED — no key under it is a candidate', () => { + // The fence, as a test. If a future edit turns `ownSegments` into a filter, + // the three `wideNs.*` leaves become candidates and this goes red. + const { confirmed, needsReview, candidateCount } = sweep(headReportRoot()); + expect(candidateCount).toBe(0); + expect(confirmed).toEqual([]); + expect(needsReview).toEqual([]); + }); + + it('counts every leaf UNDER the head, whether or not another leg already holds it', () => { + const root = headReportRoot(); + expect(rowFor(root, 'wideNs.')?.leavesUnder).toBe(3); + expect(rowFor(root, 'narrowNs.group.')?.leavesUnder).toBe(2); + }); + + it('counts as HELD only the leaves no other leg keeps live', () => { + // `wideNs.alpha` has a literal call site, so the head is not what takes it + // out of the candidate list. Reporting it as held would overstate the + // head's reach — the same overstatement in the opposite direction to the + // silence this leg closes. + const root = headReportRoot(); + expect(rowFor(root, 'wideNs.')?.heldLive).toBe(2); + expect(rowFor(root, 'narrowNs.group.')?.heldLive).toBe(2); + }); + + it('marks where each head came from', () => { + const root = headReportRoot(); + expect(rowFor(root, 'wideNs.')?.via).toBe('direct'); + expect(rowFor(root, 'narrowNs.group.')?.via).toBe('direct'); + }); + + describe('measured on THIS repository', () => { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const result = sweep(repoRoot); + + it('reports one row per head the sweep actually applied — no head goes unreported', () => { + const gate = analyze(repoRoot); + const indirect = collectIndirectTemplateHeads(repoRoot, collectEnKeys(repoRoot)); + const applied = new Set([...gate.dynamicHeads, ...indirect.heads.keys()]); + expect(applied.size, 'no heads at all — the whole cross-check would be vacuous').toBeGreaterThan(0); + expect(result.appliedHeads.map((row) => row.head).sort()).toEqual([...applied].sort()); + }); + + it('every reported row describes a REAL subtraction — no key it counts is still a candidate', () => { + // The report must be a reading of the candidate list, not a parallel + // claim about it. A row whose keys were still candidates would be worse + // than the silence it replaces. + const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]); + const leaves = [...collectEnKeys(repoRoot).leaves]; + for (const row of result.appliedHeads) { + const under = leaves.filter((key) => key.startsWith(row.head)); + expect(under.length, `${row.head} holds no leaves — its row would be vacuous`).toBe(row.leavesUnder); + expect(under.filter((key) => candidates.has(key)), `${row.head} reaches a key still in a tier`).toEqual([]); + } + }); + + it('⛔ the reading does not move the candidate list — heads stay unfiltered at EVERY depth', () => { + // The card's lit control, at repository scale. The pack half applies each + // collected head whatever its depth; the report says so and changes + // nothing. `heldLive` summing to the keys held out of the tiers is the + // arithmetic a reviewer can redo from the printed table. + const leaves = [...collectEnKeys(repoRoot).leaves]; + const heldByAHead = leaves.filter((key) => result.appliedHeads.some((row) => key.startsWith(row.head))); + const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]); + expect(heldByAHead.some((key) => candidates.has(key))).toBe(false); + expect(result.candidateCount).toBe(result.confirmed.length + result.needsReview.length); + }); + + it('⛔ does NOT adopt the designer half’s threshold for the packs', () => { + // objectui#9126 fences the threshold off as a maintainer's call. A head + // with no segment of its own is REPORTED here and REFUSED on the designer + // corpus; if the packs ever start refusing one too, that is a deliberate + // decision and this test is where it gets re-argued. + const rootOnly = result.appliedHeads.filter((row) => row.ownSegments < 2); + expect(rootOnly.length, 'no namespace-wide head on the tree — the pin has nothing to hold').toBeGreaterThan(0); + const leaves = [...collectEnKeys(repoRoot).leaves]; + const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]); + for (const row of rootOnly) { + const under = leaves.filter((key) => key.startsWith(row.head)); + expect(under.filter((key) => candidates.has(key)), `${row.head} was filtered, not merely reported`).toEqual([]); + } + }); + }); +}); diff --git a/scripts/check-i18n-dead-keys.mjs b/scripts/check-i18n-dead-keys.mjs index 30e30037c7..dd6a9de783 100644 --- a/scripts/check-i18n-dead-keys.mjs +++ b/scripts/check-i18n-dead-keys.mjs @@ -1590,6 +1590,9 @@ export function collectIndirectTemplateHeads(root, packKeys = collectEnKeys(root * needsReview: Array<{ key: string, hits: string[] }>, * byNamespace: Map, * indirectTemplateHeads: ReturnType, + * appliedHeads: Array<{ + * head: string, ownSegments: number, leavesUnder: number, heldLive: number, via: string, + * }>, * }} */ export function sweep(root) { @@ -1619,6 +1622,46 @@ export function sweep(root) { }) .sort(); + // objectui#9126 — what the head leg above ACTUALLY applied, as a reading. + // + // REPORT ONLY, and the ORDER of these two statements is the whole guarantee: + // `candidates` is already computed, from `heads` unfiltered, exactly as + // before. Nothing below feeds back into it — this block only measures a + // subtraction that was already made, so the candidate list is byte-identical + // with and without it. That is deliberate and fenced: the sibling corpus + // below DOES refuse a head with no segment of its own (`MIN_HEAD_SEGMENTS`), + // and whether the packs should adopt a threshold of their own is a + // maintainer's call this script does not make. What it can do without any + // threshold decision is stop being SILENT about it — a reader who sees only + // the candidate count has no way to learn how many keys were never offered, + // nor that a handful of heads account for nearly all of them. + // + // `ownSegments` is that reading, not a filter: the head minus its trailing + // dot, counted in segments, so a head naming ONLY a top-level namespace + // (`someNamespace.` -> 1) is visible as such in the report while still being + // applied in full. + // + // `heldLive` is the designer half's `headHeldCounts` definition, mirrored: of + // the leaves under this head, how many NO OTHER leg here already holds — i.e. + // exactly the keys this head alone keeps out of `candidates`. Heads do not + // nest today, but if two ever did, a leaf under both counts in both rows; + // the sibling counts the same way, and a per-head row that quietly dropped + // shared keys would under-report the same way this block exists to fix. + const appliedHeads = heads + .map((head) => { + const under = [...leaves].filter((key) => key.startsWith(head)); + return { + head, + ownSegments: head.replace(/\.$/, '').split('.').length, + leavesUnder: under.length, + heldLive: under.filter( + (key) => !referencedKeys.has(key) && !branchPrefixes.some((prefix) => key.startsWith(prefix)), + ).length, + via: dynamicHeads.has(head) ? (indirect.heads.has(head) ? 'direct+indirect' : 'direct') : 'indirect', + }; + }) + .sort((a, b) => b.leavesUnder - a.leavesUnder || a.head.localeCompare(b.head)); + const footprints = textFootprint(root, candidates); const confirmed = candidates.filter((key) => footprints.get(key).length === 0); const needsReview = candidates @@ -1645,6 +1688,7 @@ export function sweep(root) { needsReview, byNamespace, indirectTemplateHeads: indirect, + appliedHeads, }; } @@ -2118,6 +2162,9 @@ if (invokedDirectly) { candidateCount: result.candidateCount, confirmed: result.confirmed, needsReview: result.needsReview, + // objectui#9126 — every head the pack half applied, and what each + // holds. Reporting only: `candidateCount` above is unchanged by it. + appliedDynamicHeads: result.appliedHeads, indirectTemplateHeads: { counters: result.indirectTemplateHeads.counters, heads: Object.fromEntries( @@ -2219,6 +2266,54 @@ if (invokedDirectly) { } } + // ── the dynamic heads this half APPLIED (objectui#9126) ───────────────── + // Every head the candidate list above was computed against, printed + // because until now it was not: this half applies each collected head + // unfiltered, so a key is held live by merely starting with one, and the + // report said nothing at all about which heads those were or how much each + // reached. The counts read as a candidate list that is simply short. + // + // ⛔ This block changes no verdict. The sibling corpus below REFUSES a head + // with no segment of its own; whether the packs want a threshold of their + // own is a maintainer's call and is deliberately not made here. Reporting + // needs no such decision — it only turns the silence into a reading. + { + const rows = result.appliedHeads; + const totalUnder = rows.reduce((sum, row) => sum + row.leavesUnder, 0); + const totalHeld = rows.reduce((sum, row) => sum + row.heldLive, 0); + const rootOnly = rows.filter((row) => row.ownSegments < 2); + const rootOnlyUnder = rootOnly.reduce((sum, row) => sum + row.leavesUnder, 0); + const rootOnlyHeld = rootOnly.reduce((sum, row) => sum + row.heldLive, 0); + console.log( + `\n${'='.repeat(78)}\ndynamic template heads APPLIED to the pack corpus — all ${rows.length}, ` + + 'none filtered' + + `\n\n"under" is every en leaf sharing the head. "held" is how many of those NO other leg here ` + + `already keeps live — i.e. exactly the keys this head alone takes out of the candidate list, ` + + `so ${result.candidateCount} candidate(s) is a reading of the pack MINUS ${totalHeld} key(s) ` + + `across ${rows.length} head(s) (${totalUnder} leaves fall under a head in total; the ` + + 'difference is leaves a literal call site holds anyway). "own" is how many segments the head ' + + 'names beyond nothing: 1 means it names a top-level namespace and no more. Each head comes ' + + 'from a real call site building a key from a runtime value — the row measures its REACH, and ' + + 'says nothing about whether the head is right:', + ); + console.log(`\n ${'head'.padEnd(40)} ${'own'.padStart(3)} ${'under'.padStart(5)} ${'held'.padStart(5)} via`); + for (const row of rows) { + console.log( + ` ${row.head.padEnd(40)} ${String(row.ownSegments).padStart(3)} ` + + `${String(row.leavesUnder).padStart(5)} ${String(row.heldLive).padStart(5)} ${row.via}`, + ); + } + if (rootOnly.length > 0) { + console.log( + `\n ⚠️ ${rootOnly.length} of those head(s) name a top-level namespace and nothing else ` + + `(${rootOnly.map((row) => row.head).join(', ')}), together reaching ${rootOnlyUnder} leaf/leaves ` + + `and holding ${rootOnlyHeld} key(s) out of the candidate list on their own. That is the class ` + + 'the designer half below refuses outright and this half applies in full. Both are reported; ' + + 'only one is a decision, and it is not this one.', + ); + } + } + // ── the one-hop indirect template leg (objectui#8754) ─────────────────── // Printed for the same reason the property-read rows above are: the leg // subtracts keys from the candidate set, so the tiers are shorter than they