fix(scripts): follow a key template assigned one hop before t() in the dead-key sweep (objectui#8754 round 1) - #9222
Conversation
…e dead-key sweep `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
✅ ACCEPT — reviewed against the DIFF, not the report. Probe below, posted BEFORE the merge.
§0 — the four things I checked myself, and one the report did not claim1. The self-pollution trap — CLOSED, and I verified it from the diff rather than from the measurement. ⛔ Not one real pack key is spelled in 2. ⭐ The arithmetic closes, and it is the check that matters most. The report gives 3. The pin's red mechanism traces in the code. 4. The declared gap is itself guarded. The header now names both objectui#7844 paths, and a test asserts the header keeps naming them: expect(header).toContain('apps/console/src/pages/settings/useSettingsLabel.ts');
expect(header).toContain('packages/i18n/src/useObjectLabel.ts');⇒ a future edit that quietly drops the declaration reds. ⭐ A declared gap is this repo's accepted state; this makes the declaration load-bearing rather than decorative. ⛔ Not weakened: the diff only adds. The probe — ⛔ written and posted BEFORE the merge, to be run scoped to
|
| string | file | before | after |
|---|---|---|---|
export function collectIndirectTemplateHeads |
check-i18n-dead-keys.mjs |
0 | 1 |
const TRANSLATOR_CALL_TEXT |
check-i18n-dead-keys.mjs |
0 | 1 |
indirectTemplateHeads |
check-i18n-dead-keys.mjs |
0 | ≥3 |
collectIndirectTemplateHeads |
check-i18n-dead-keys.test.ts |
0 | ≥5 |
L3 — ⛔ what must STAY (the leg that beats one checking what left):
export function sweep— present exactly once on BOTH sides ofcheck-i18n-dead-keys.mjs.occursAtKeyBoundary— occurrence count unchanged across the merge; ⭐ print the matched lines, since this file is dense with prose naming its own functions.ANALYSED_PACK_OBJECT_IMPORTERSandderivePackObjectImporters— present on both sides, count unchanged.- ⭐
EXCLUDED_TRANSLATORS—0 → 1in the import line; this is what boundary 3 rests on, so its absence would be silent.
L4 — the self-pollution trap, two-sided. In scripts/__tests__/check-i18n-dead-keys.test.ts: search.type and search.badge each 0 on BOTH sides. SearchResultsPage.tsx is 0 → ≥1 in that same file — so the zero above is a statement about keys, ⛔ not about a grep that matched nothing.
L5 — the declared gap. In check-i18n-dead-keys.mjs: apps/console/src/pages/settings/useSettingsLabel.ts 0 → ≥1 and packages/i18n/src/useObjectLabel.ts 0 → ≥1.
L6 — dynamicFamilies stays out of the code. In check-i18n-dead-keys.mjs, every occurrence of dynamicFamilies on the merge side must be a comment line — ⭐ print the matched lines and show each is prefixed by * or //. dynamicHeads appears in code on both sides (const { referencedKeys, referencedBranches, dynamicHeads } = analyze(root);).
L7 — blobs. git rev-parse <merge>:<path> for all three files matches what ae75edbc43 carried, ⛔ so a queue rebase that altered content cannot pass as a clean landing.
L8 — changeset frontmatter EMPTY. First two lines of the changeset are --- and --- ⇒ no package released.
⛔ On landing: do NOT close objectui#8754. Round 2 (the deletion) is still owed on that card. Strip pm:dispatched + assignee and return it to pm:queue for round 2's grading.
Generated by Claude Code
🔫 ARMED — auto-merge on, head
|
✅ LANDED —
|
| string | file | before → after |
|---|---|---|
export function collectIndirectTemplateHeads |
script | 0 → 1 |
const TRANSLATOR_CALL_TEXT |
script | 0 → 1 |
indirectTemplateHeads |
script | 0 → 6 |
collectIndirectTemplateHeads |
test | 0 → 7 |
L3 — what must STAY ✅, ⚠️ and two of my own expected values were wrong
| string | before → after | |
|---|---|---|
ANALYSED_PACK_OBJECT_IMPORTERS |
7 → 7 | ✅ unchanged |
derivePackObjectImporters |
9 → 9 | ✅ unchanged |
EXCLUDED_TRANSLATORS |
0 → 2 | ✅ boundary 3's import + use |
export function sweep |
2 → 2 | |
occursAtKeyBoundary |
6 → 7 |
export function sweepreads 2 becausegrep -Falso matchesexport function sweepDesignerTable. Both functions are present on both sides (:1337/:1708before,:1574/:1950after). ⇒ the invariant holds; ⛔ my stated expectation was wrong for the classic substring reason this lane already has an errata for.occursAtKeyBoundarymoved 6 → 7 — and the +1 is prose, at:1402, inside the newly added leg's docblock. Code-only occurrences are 3 → 3, at the identical three sites (the definition, thekeyBoundary ?ternary, andspelledHere):
before: 542 function occursAtKeyBoundary(...) · 647 const literal = keyBoundary ? ... · 1235 const spelledHere = ...
after : 575 function occursAtKeyBoundary(...) · 680 const literal = keyBoundary ? ... · 1268 const spelledHere = ...
⇒ ⭐ the text safety net is untouched. A raw count read "something moved"; the printed lines read "prose moved, code did not" — which is exactly the failure mode the print requirement exists for.
L4 — the self-pollution trap, TWO-SIDED, with a control that HITS ✅
search.type 0 -> 0 (both sides)
search.badge 0 -> 0 (both sides)
SearchResultsPage.tsx 0 -> 1 <-- CONTROL, hit in the same run
1560: const HOP_SITE = 'packages/app-shell/src/views/SearchResultsPage.tsx';
⭐ The control is the load-bearing half: the test file does reach that call site, by path, and still spells no key. ⇒ the zeros are a statement about keys, ⛔ not a grep that matched nothing. The trap is closed, verified on the landed tree.
L5 — the declared gap ✅
apps/console/src/pages/settings/useSettingsLabel.ts 0 → 2 · packages/i18n/src/useObjectLabel.ts 0 → 2. objectui#7844's two dark sub-shapes are named in the shipped script.
L6 — dynamicFamilies stays OUT of the code ✅
All three occurrences on the merge side are comments — :1439 and :1443 in the docblock, :1579 a // line. Code-only count = 0. Control HITS in code at :1577:
const { referencedKeys, referencedBranches, dynamicHeads } = analyze(root);⇒ reachability only; the call-site gate's family registry is genuinely untouched.
L7 — blobs ✅
566b012b8adf8bb3562a9eb95d3a19991a9f99a4 script · f5aa01bb3aa675239dd267d84092cf8a667088a7 test · 78aa4989aa5995198221c316bb89e32100deaa66 changeset.
L8 — changeset frontmatter EMPTY ✅
---$
---$
$
No package released.
⛔ objectui#8754 is NOT closed. Round 2 — the deletion, from the re-derived 119 and under the ruling's family rule — is still owed on that card. pm:dispatched and the assignee are stripped and it returns to pm:queue for round 2's grading.
Generated by Claude Code
Round 1 of objectui#8754 — the instrument round of the 2026-09-12 ruling (
#issuecomment-5642106953). ⛔ Round 2 (the deletion) is not started and is still owed on the same card, so this PR deliberately carries no closing keyword.Base:
mainatb775500af677ec876b5f6dad443028e7f9ed6fbf. Head measured for every figure below:ae75edbc43.⛔ No pack key is deleted — not the 8, not the 127, not the 25. ⛔ No budget constant is read or moved. ⛔ No NEEDS-REVIEW key is touched. ⛔ No gate is weakened: no threshold lowered, no ratchet raised, no non-vacuity floor lowered, no test skipped.
1. The leg
collectIndirectTemplateHeads()inscripts/check-i18n-dead-keys.mjsfollows a key template assigned to a local one hop beforet()— the shape that hid the eight:…passed as bare identifiers at
:297and:320, with nodefaultValue. Relocated by content, not by the line numbers the previous round read on72bcd7783.Every leg was blind at once, and here are the enforcing lines rather than the claim:
scripts/check-i18n-call-site-keys.mjs:1764:function staticHead(argument) { const inner = unwrapExpression(argument); … if (!inner || !ts.isTemplateExpression(inner)) return '';⇒ an identifier yields''and the site is counted asheadlessDynamicKeySites.check-i18n-call-site-keys.mjs:1822:if (ts.isBlock(body)) { if (body.statements.length !== 1 || !ts.isReturnStatement(body.statements[0])) return null;⇒ aconstin the middle of a render is not one.check-i18n-call-site-keys.mjs:2249:const hasKeyTemplate = text.includes('.${');— measured on the file that hid the eight:grep -c '\.\${' packages/app-shell/src/views/SearchResultsPage.tsx→ 0, exit 1. The head ends mid-segment (search.type), so the "adjacency a dotted key template always has" is absent. This leg therefore pre-filters on the translator call alone and pays 322 parsed files instead of 51.check-i18n-dead-keys.mjspropertyChainProbe()returnsnullbelow three segments; these keys are two.occursAtKeyBoundary()correctly refuses a prefix as evidence about a longer key. That refusal is right; it is what leaves the tier silent.Three boundaries, two of them objectui#7592's own: one hop and the same file; the head must resolve against
en; the registered module-local tables are skipped. All four are pinned on synthetic repos.⛔ What it deliberately does NOT do
It feeds
dynamicHeads(reachability) only — neveranalyze()'sdynamicFamilies, whose undeclared branch raisesundeclared-dynamic-family, a RED finding ofcheck:i18n-keys. That is why the leg lives in the reverse-sweep script rather than in the gate. Measured after the change:dynamicFamilies.sizeis 26,undeclared-dynamic-familyfindings 0, andcheck:i18n-keysprintsDynamic key families: 26 declared— byte-identical tomain. The asymmetry is pinned by a test, so wiring the leg into the gate becomes a conscious act.missing-membernever fires for it. What the leg buys is that the members that do exist stop reading as dead.2. The pin — ⛔ and how it avoids the self-pollution trap
⛔ Not one pack key is spelled in
scripts/__tests__/check-i18n-dead-keys.test.ts. Everything is derived on the run that reads it:collectIndirectTemplateHeads(), off the view's own source;readVocabulary()over the view's own closed discriminator (interface SearchResult { type: … }) — the same readerDYNAMIC_KEY_FAMILIESuses;en.The only things spelled are the call site's path and the union's name, and neither is a key. Proven rather than argued — the same run, before vs after the whole change:
⇒ the NEEDS-REVIEW set is set-identical and nothing joined CONFIRMED, so the new prose and the new test added zero textual footprints. That is the trap this card's "What NOT to do" names, measured closed.
Per PR objectui#8753's pattern, the guards are proven to throw on an emptied set inside the harness:
headCoversDiscriminator()throws on an empty discriminator, on a short pack, and on an empty pack, and returns the leaves when the cross product is whole — four tests, so the pin cannot pass vacuously.3. The ablation — both directions, restore proven by state
Deleted the eight leaves from all ten packs (the real retirement shape, so all-locales key parity cannot mask the reading). Script carried
trap restore EXIT INT TERMwith paths resolved fromgit rev-parse --show-toplevel, and aborts as a void run unless the counts match.On-disk mutation proof, both directions, before the run:
leafLines=8in each of the ten packs, 80 total;git hash-objectequal togit rev-parse HEAD:PATHfor all ten.git diff --stat→10 files changed, 80 deletions(-).before==80 && after==0 && changed==10.Tests 1 failed | 120 passed (121)—follows the assignment hop at the site that hid the keys: expected +0 to be 2pnpm --filter @object-ui/i18n testTest Files 65 passed (65),Tests 1104 passed (1104)SearchResultsPagesuiteTest Files 1 passed (1),Tests 1 passed (1)node scripts/check-i18n-dead-keys.mjs357 candidate(s) … 119 CONFIRMED … 238 NEEDS-REVIEW— the same three numbers as the healthy tree⇒ the 2026-09-10 reading reproduces exactly: everything except the new pin stays green through the deletion.⚠️ And the last row is the sharpest part of it — the CONFIRMED count alone cannot tell the two trees apart. What changes in the report is the head row (
3 head(s)→1 head(s), holding 23 → 15 leaves). The report is the reading; the test is the pin. The mechanism is the leg's own boundary 2: delete the keys and the head stops resolving againsten, so the leg stops seeing the site — a detection leg degrading to a no-op exactly when it matters, which is precisely what the floor assertion reds on.Restore proven by STATE, never by an exit code:
git diff HEAD --name-onlyempty,git status --porcelainempty, andgit hash-objectequal togit rev-parse HEAD:PATHfor all thirteen files inpackages/i18n/src/locales/(an empty hash is read as failure, not as "nothing to compare").4. The re-derived census, with lit/dark controls
node scripts/check-i18n-dead-keys.mjs, exit code captured by redirect before any pipe →EXIT=0. The verdict line the script itself printed:and its new section:
⭐ New CONFIRMED count: 119 (was 127).
Controls, each naming its population:
SearchResultsPage.tsx. Both collected, one site each (:297,:320), 4enleaves under each. Hits.marketplace.category.is collected by the new leg and was already inanalyze().dynamicHeadsbefore it. Hits — the leg reads a real shape, not only the two it was written for, and the overlap proves the merge is a union rather than a replacement.t()arguments that do NOT resolve one hop (18 examined in files holding a templated local; 40 repo-wide before that narrowing), which includes everyt(key)in themetadata-admininspectors. 0 heads. Files contributing any head: exactly two —MarketplacePage.tsxandSearchResultsPage.tsx.enpack minus the eight. 0 keys joined either tier; NEEDS-REVIEW set-identical at 238. The subtraction is exactly the eight and nothing else.5. ⭐ objectui#7844 — measured, ⛔ not silently widened
The card's own boundary: "the boundary is not 'resolver' or 'array'; it is that the key expression is not at the call site." Explicit verdict on each, measured on
ae75edbc43after the fix:actions.)apps/console/src/pages/settings/useSettingsLabel.ts:135,137,139analyze().dynamicHeads, and fromdynamicFamiliesfields.)packages/i18n/src/useObjectLabel.ts:217The enforcing line is this leg's own:
if (argument && ts.isIdentifier(argument)). Both #7844 shapes put the template in an argument or an array element, never in an assignment, so the hop is not an assignment hop and the identifier test never sees them. ⛔ I did not widen to reach them: objectui#7592 measured that "any template literal whose head resolves" matches 28 heads repo-wide, 25 already declared, and firesundeclared-dynamic-familyon both of these at once — one inside the excluded metadata-admin tree. That gate ran green here precisely because the leg was not widened.⇒ the gap is declared, not silent: both paths are now written into the script's own "What CONFIRMED does NOT guarantee" class 2, and a test reds if the header stops naming them. Today neither head holds a CONFIRMED key, so nothing is at risk through them — but that is a fact about what else happens to spell those keys, the same "by luck, not by design" state class 1 records, and it is now on the page rather than in nobody's memory.
Gates run locally (exit captured by redirect before any pipe)
check:i18n-keys0 ·check:i18n-dead-keys0 ·check:i18n-drift0 ·check:i18n-designer-parity0 ·check:control-bytes0 ·check:new-line-citations0 ·check:changeset-claims0 ·check:entry-guard0 ·check:comment-mask-corpus0 ·check:unreferenced-sources0 ·check:shell-escape-residue0 ·check:test-path-roots0 ·check:vi-mock-specifiers0 ·check:vi-mock-inherit0 ·check:vi-mock-override-shape0.Suites:
scripts/__tests__/check-i18n-dead-keys.test.ts→Tests 121 passed (121);check-i18n-call-site-keys.test.ts+scripts-type-check.test.ts+check-type-check-coverage.test.ts+package-scripts-vitest-projects.test.ts→Tests 197 passed (197);pnpm --filter @object-ui/i18n test→Tests 1104 passed (1104).pnpm lint(eslint . --no-inline-config) run over the whole repo atae75edbc43, not narrowed: exit 0,Tasks: 47 successful, 47 total,0 errors.Note on a known adjacency, ⛔ not chased
objectui#8752's subject is already fixed on this tip — the header states no importer total,
derivePackObjectImporters()derives it per run, andANALYSED_PACK_OBJECT_IMPORTERSalready carriespackages/react/src/utils/nonGridRowCeiling.tsxas the fifth. Nothing to correct; the count is not stale.Changeset
.changeset/8754-i18n-dead-keys-one-hop-indirect-template-leg.md, empty frontmatter — test and tooling only, no package is released.维护者速读(草稿)
改了什么。 给「查死键」的工具补了一只眼睛:一个组件先把键名拼进一个局部变量,下一行才交给
t()—— 这一跳以前所有检查腿都看不见,于是那个键被归到「最有把握是死的」那一档,而屏幕上正在渲染它。补完之后普查从 127 降到 119,少掉的正是那 8 个搜索页的键。⛔ 一个键都没有删。为什么改。 9 月 10 日实测过:把这 8 个键从十个语言包里全删掉,1090 个测试全绿、页面自己的测试也绿、工具本身照样不吭声。这一类问题上「CI 是绿的」等于零信息。所以这一轮先修工具、再加一道钉子,删除留到第二轮。
风险与代价(含回滚)。 只动了
scripts/下的检查脚本和它的测试,不发布任何包,不改任何预算常量,不碰门禁阈值。新腿只做「可达性」判断,不进调用点门禁的族注册表 —— 那里多一个头就会红一次(undeclared-dynamic-family),实测改完之后那个门禁的读数和 main 上一字不差(26 个族、0 个未声明)。回滚 = revert 这一个 PR,普查数字回到 127,没有别的连带。席位意见。
你要做的。 看一眼两件事:① 119 这个数字和它的 lit/dark 对照;② 那道钉子的写法 —— 它一个键名都没写出来,全部从源码现推,所以不会把自己变成「这些键还有人提」的证据。确认后可以派第二轮(删除)。
🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code