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