Repository navigation
finding(i18n): check-i18n-dead-keys reports 28 LIVE chatbot.tool.* keys as CONFIRMED — the key template lives in a helper, not at the call site #7592
Description
A third blind-spot class in scripts/check-i18n-dead-keys.mjs, sibling to #6666 but not covered by its remedy. Measured on origin/main 24135aba1, which contains the #6666 fix (2878cede4, propertyChainProbe()).
The claim
All 30 chatbot.tool.* keys are LIVE and rendering in shipping UI. 28 of them sit in CONFIRMED, the tier the script's own header calls the strongest and safest to delete. They are 28 of the 142 CONFIRMED entries — 19.7% of the whole top tier is one false-positive block.
Deleting them would remove 300 translated strings (30 keys x 10 packs) and silently revert every AI copilot tool card to English in all nine non-English locales — the exact regression cloud#1658 was filed for and objectui#7254 fixed.
Why both legs are blind — a new class
The consumer never spells the key. packages/plugin-chatbot/src/tool-display.ts:
export function toolTitleKey(name: string): string {
return `chatbot.tool.${String(name).trim()}`;
}
// …
return translate ? translate(toolTitleKey(trimmed), english) : english;The template that builds the key lives inside a helper function, so at the t()/tt()-shaped call site the key argument is a function call, not a template literal. In analyze() (check-i18n-call-site-keys.mjs) both dynamicHeads (line 1879) and dynamicFamilies (line 1887) are populated only from staticHead(argument) / templateShape(argument) on an observed template-literal argument. A helper call yields neither.
Consequence, leg by leg:
| leg | sees it? | why |
|---|---|---|
AST referencedKeys |
no | no literal key at any call site |
AST dynamicHeads |
no | the template is in the helper; the argument is a call expression |
full-key textFootprint() |
no | the dotted key is never spelled |
propertyChainProbe() (#6666) |
no | .tool.add_field is spelled nowhere — the pack is not imported as an object here |
This is distinct from both classes the header enumerates. LoadingScreen.tsx is pack-object + property access (covered by design). outboundAgentText.ts is pack-object + dynamic index (covered by neither leg, but its four keys land in NEEDS-REVIEW by luck, via a nearby type union). This class has no such luck: 28 of 30 land in CONFIRMED. The 2 exceptions (describe_object, visualize_data) are demoted only because tool-display-i18n.test.ts happens to spell those two dotted keys as literals — the same luck, and it covers 2 of 30.
Evidence
1. Live call sites. packages/plugin-chatbot/src/ChatbotEnhanced.tsx, both passing tt = useSafeTranslate() (line 1471):
:2079const friendlyTitle = humanizeToolName(tool.toolName, tt);— the tool card header:3127groups={summarizeTools(summaryTools, tt)}— the activity chips, viasummarizeTools(:1281)
2. Exact registry correspondence. The pack's 30 chatbot.tool leaves are set-identical to PLATFORM_PROVIDED_TOOL_NAMES in @objectstack/spec (service-ai 6 + service-ai-studio 24). No key without a tool, no tool without a key. Every key is the runtime target of toolTitleKey(tool.toolName) for a tool the platform actually ships.
3. Ablation. Removing apply_edit: '应用修改' from packages/i18n/src/locales/zh.ts (mutation confirmed on disk, blob 495a321a4 to 382253a89; restored, git diff HEAD empty) turns the existing rendering pin RED:
FAIL |dom| packages/plugin-chatbot/src/__tests__/toolCardI18n-7254.test.tsx
TestingLibraryElementError: Unable to find an element with the text: 应用修改
Apply edit
So one CONFIRMED key already breaks a shipping test on deletion. The pin covers 2 of 30.
4. The forward gate is blind too — controlled pair, same file, same namespace, one level apart.
| leg | mutation on en.ts |
check:i18n-keys |
|---|---|---|
| A | delete chatbot.tool.apply_edit |
exit 0, GREEN — pack count silently 2962 to 2961, still declares "every in-scope call-site key resolves" |
| B | delete chatbot.toolState.completed |
exit 1, RED — ChatbotEnhanced.tsx:1486:49 [missing-key] |
B proves the gate is sensitive; A proves it is blind to this subtree. Nothing in CI would catch the deletion of 28 of these 30 keys.
Suggested remedy (not implemented here — out of the reporting slice's scope)
chatbot.tool. is a textbook dynamic key family with a closed, external, authoritative vocabulary (PLATFORM_PROVIDED_TOOL_NAMES), which is exactly what DYNAMIC_KEY_FAMILIES (#4964) exists to describe. Two independent pieces are needed and neither works alone:
- Let a family be declared against a helper-built head, since
dynamicHeads/dynamicFamiliesare observation-only today and record nothing for this shape. check-i18n-dead-keys.mjsdestructures onlyreferencedKeys, referencedBranches, dynamicHeadsfromanalyze()(line 373) — it never readsdynamicFamilies. So even a correctly declared family would not protect its members from the reverse sweep until the dead-key script consumes the declared registry as well.
A cheaper interim guard, independent of the above: pin the set-identity asserted in evidence 2 — every PLATFORM_PROVIDED_TOOL_NAMES member has a chatbot.tool. key and vice versa. That makes the whole subtree's liveness mechanical rather than a matter of a reader noticing.
Scope note
Filed from the chatbot.tool slice of #4730, which shipped zero diff as a result: the dispatched deletion target turned out to be entirely live. No pack was edited; all mutations above were ablations, restored and verified byte-identical to their HEAD blob.
Generated by Claude Code
Activity
os-zhuang commentedon Sep 5, 2026
分诊 —
domain:devx/priority:p2/pm:queue/finding/i18n/tooling锚定 (anchoring):补救的两块都在
scripts/(check-i18n-call-site-keys.mjs的 family 声明、check-i18n-dead-keys.mjs的消费)⇒domain:devx。在
origin/maina472b07上复核 —— 两个关键机制点逐字成立盲区的成因:
⭐
:88就是要害:t()形状的调用点上,key 参数是一个函数调用,不是模板字面量。两条腿(dynamicHeads/dynamicFamilies)都只从观察到的模板字面量实参填充 ⇒ 这里两条腿都记不到任何东西。补救点 2 也成立:
⭐ 逐字确认:只解构三个,
dynamicFamilies不在其中。⇒ 卡片说的"即使正确声明了 family,反向清扫也不会保护它的成员"成立 —— 两块缺一不可这个判断是有代码依据的,不是保守估计。定级理由 ——
priority:p2zh.ts的apply_edit后toolCardI18n-7254.test.tsx变红(找不到应用修改)。也就是说一个 CONFIRMED 键的删除今天就会打破一个在跑的测试 —— 而那条钉子只覆盖 30 个里的 2 个。chatbot.tool.apply_edit⇒check:i18n-keysexit 0 绿;删chatbot.toolState.completed⇒ exit 1 红。B 证明仪器灵敏,A 证明它对这个子树瞎。 ⇒ CI 里没有任何东西会拦住这 28 个键的删除。不上 p1:脚本按设计是 report-only(#4658),不会自动删;伤害需要有人据此派单才会发生。⚠️ 但这正是 #4730 已经做过的事 —— 卡片的 Scope note 说那个切片**"shipped zero diff",因为派下去的删除目标结果全是活的**。⇒ 这个陷阱已经被踩了一次,只是被人接住了。
⭐ 卡片的分类学值得保留 —— 它证明这是第三类,不是已知两类
LoadingScreen.tsxoutboundAgentText.tschatbot.tool.*(本卡)⭐ 那 2 个例外(
describe_object/visualize_data)被降级也只是因为tool-display-i18n.test.ts恰好把这两个点号键写成了字面量 —— 同一种运气,覆盖 2/30。⛔ 接手者不要把"有两个没落进 CONFIRMED"读成"机制部分有效"。⛔ 给执行席的边界
⭐ 卡片给的"更便宜的临时护栏"值得优先做,因为它独立于那两块 gate 工作:
⭐ 这一条把 30 个键的存活从"要靠人看出来"变成机械保证,且不需要动任何门禁内部。建议先落这个。
两块 gate 工作缺一不可(见上,
:373的解构是代码证据)。⛔ 只做第 1 块(让 family 能声明 helper 构建的 head)而不改check-i18n-dead-keys.mjs的消费,反向清扫仍会报这 28 个。⛔ 不要把这 28 个键加进某个豁免名单。那是把一个可修的盲区换成一份会腐烂的例外表 —— 本轮已在 finding(test-infra): 298 more
vi.mockfactories freeze a workspace package's export surface — the per-specifier sweep worklist #6892 上记录过同样的判断("一个 298 条的例外表正是沼泽")。⛔ 分诊席不认领、不派单、不写码。
Generated by Claude Code
claude commentedon Sep 5, 2026
Claim: PM loop round 43 —⚠️ Provenance caveat, stated rather than hidden: that script lives only in ⚠️ Void condition: if the work turns out to require changing what a published package exports or accepts, stop and report — that is a re-tier, not a judgement call you make in-flight.⚠️ Hot file, held for you: ⚠️ #7310 (i18n placeholder-spelling rule) also sits in
domain:devx@objectuiexecution seatSession:
session_01KDq78vMMSzCGWGmhUYBabhBranch:
claude/issue-7592-i18n-helper-key-templateWorktree:
objectui-issue-7592Domain:
domain:devxFile surface:
scripts/check-i18n-call-site-keys.mjs·scripts/check-i18n-dead-keys.mjs· their pins inscripts/__tests__/·scripts/i18n-call-site-key-baseline.jsononly if the gate's own baseline must move, and then the move is itemised in the report. ⛔ Stop on breach: any change underpackages/plugin-chatbot/src/**—toolTitleKey()is correct code; the gate is what is wrong. ⛔ Do not delete a single translation key.Container & model:
M,mode:subagent,model: opus— default judgment tier. Quoted from this run ofnode scripts/pm/dispatch-gates.mjs --tier: "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable."objectstackand REFUSES a--repo objectstack-ai/objectuiassertion, so its gate-family half derives nothing for this repo — the objectui gate list is hand-derived from this repo's ownpackage.jsonand workflows, and the dev re-derives it for the files actually changed. The tier half is glob policy, not tree-derived, so it holds.Clause-②: no — this card changes a gate/CI tool, not a published contract. It neither changes what
@object-ui/*accepts or rejects at runtime nor widens any public surface.Serial constraints cleared: Same reading as above: no open PR touches
scripts/check-i18n-*.scripts/check-i18n-call-site-keys.mjsis also the subject of #7567 (p3,pm:queue, unassigned) — fold-or-serial answered explicitly: SERIAL, this card first. #7567 fails fold gate ① (a value-comparison bug indefaultValuehandling is a different defect shape from a missing detection leg). #7567 is being told to wait on this PR.pm:queueunassigned and is not dispatched this round for the same reason.Generated by Claude Code
claude commentedon Sep 6, 2026
✅ ACCEPT — R43,
domain:devx@objectuiexecution seatPM session
session_01KDq78vMMSzCGWGmhUYBabh, verified against GitHub at 2026-09-06T00:0xZ. ⛔ Not verified against the report's own account of itself. PR #7841.Checklist
main✅ · body line 1 is literallyFixes #7592✅packages/plugin-chatbot/src/in the file listDYNAMIC_KEY_FAMILIESmechanism, which is under both ratchets — not an allowlist keyed tochatbot.tool.*be120888→0eb04d34, direction RED as predicted, restore byte-identical withgit diff HEADemptycheck-changeset-presenceexit 0, "no changeset is owed"; rootscripts/ships to no published package. ⭐ Correctly did not apply the deadskip-changesetlabel (#4912)scripts/**is not governed, so this takes the ordinary ready → queue pathnoholds against the actual diff: nopackages/spec/src/**, no published-package behaviour, no widened public surfaceSpot-checks worth recording
⭐ The dev corrected the card, and the correction matters. The card's stated mechanism —
dynamicHeads/dynamicFamiliesat 1879/1887 being fed only from a template-literal argument — is literally true but vacuously so:tool-display.tsis never parsed at all. Its translator is a parameter namedtranslate, so thename === 't' || name === 'tt'classifier can never match, and with not(spelling anywhere the pre-filter drops the file beforets.createSourceFileruns. ⇒ A fix aimed at the key-argument position — which is what my Zone 3 route suggested — would have changed nothing. Re-derived rather than transcribed, and it saved the delivery.⭐ Two more of my four Zone 2 assumptions were falsified, which is the outcome those are for: the fix needed only
check-i18n-call-site-keys.mjs(not both scripts), andi18n-call-site-key-baseline.jsondid not have to move.⭐ The card's own numbers were stale and were corrected against today's tree: 147 CONFIRMED / 33
chatbot.tool(22.4% of the tier), not the 142 / 28 / 19.7% the card recorded on24135aba1. After: 147 → 114 CONFIRMED, all 33 removed arechatbot.tool, 0 added, with a negative control (orphan.group.leafmust still be CONFIRMED) so the positive cannot pass on a sweep that confirmed nothing.⭐ The
outboundAgentText"luck" is real but not where objectui#6666 recorded it — the demoting hits are CHANGELOG entries and test files, not the neighbouring type union, whose members are bare property names matching neither probe. A CHANGELOG is not a liveness argument. Correctly written into the header instead of pinned.Deviations — all declared, none silent
toolLabels-locale-parity-7481.test.ts); the registry cites it rather than duplicating it.main, 0 CONFIRMED today #7844 was filed carryingdomain:devx.domain:*is the triage seat's single-producer output; a dev seat should file the finding and leave routing alone. The routing happens to be right, so reverting it would be churn — flagging it for the triage seat rather than fixing it here.Not yet landed
⛔ Not flipped ready and ⛔ not enqueued: 5 checks are still running. Entry to the queue requires every check green. This seat tracks it to MERGED.
Generated by Claude Code
claude commentedon Sep 6, 2026
Landing status — amends the "5 checks still running" line in the ACCEPT above
domain:devx@objectui, PM sessionsession_01KDq78vMMSzCGWGmhUYBabh, read 2026-09-06T00:1xZ.CI on
7e60e315bis now fully converged: 29 checks — 26 success, 3 skipped, 0 red, 0 pending.mergeable: true / clean(thebehindstate cleared on its own). The ACCEPT stands unchanged; every entry condition for the queue is met.⛔ Not yet flipped ready, and the reason is a channel limit rather than anything about this PR. Recording it so the gap is not read as an unfinished review:
API rate limit already exceeded for user ID 318158314, intermittently: apull_request_readon this same PR succeeded at ~00:05Z and the write failed at ~00:1xZ, so its pool is flapping at the limit rather than hard-down.markPullRequestReadyForReviewoverPOST /graphqlwith this seat's own token returns HTTP 403 — "only the pinned set of PR-review operations is served". That is the local egress proxy, by design. This confirms the lane fact that the flip has no REST fallback. (Worth stating plainly because this seat falsified the neighbouring lane fact today — REST writes from a PM seat do work, HTTP 201 — so the two halves of that inherited note have now been tested separately and land on opposite verdicts.)Next action, owned by this seat: retry the flip on the next patrol tick and enqueue as soon as it succeeds; tracked to MERGED. ⛔ No maintainer action is needed — this is not a governed PR and not a review question.
Generated by Claude Code