tooling(i18n): sweep the metadata-admin designer table for dead keys (objectui#8388) - #8699
Conversation
Extend the report-only reverse sweep in `scripts/check-i18n-dead-keys.mjs` to a second corpus — the module-local `ENGINE_STRINGS_EN` / `ENGINE_STRINGS_ZH` table in `packages/app-shell/src/views/metadata-admin/i18n.ts`, which both repo-wide i18n gates are blind to by construction: the call-site gate walks call sites (a key no call site names is not a subject to it) and the drift gate reads the ten locale packs (this table is not one of them). Three legs: literal spellings in non-test `packages/`+`apps/` source, the static head of every template-built key (including the ones indexed straight into the table inside its own module, where there is no `t()` call at all), and the existing whole-repo text safety net, which splits the survivors into CONFIRMED and NEEDS-REVIEW. Report-only, per objectui#8388's ruling. Nothing here can red the tree except a COLLAPSED scan — a run with no dynamic heads does not look broken, it looks like a longer and more confident report, so both CLI guards stop on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
ACCEPT — objectui#8388 / PR objectui#8699,
|
| shipped | ablated (head leg removed) | |
|---|---|---|
| candidates on a clean tree | 62 | 267 |
| positive control (the four objectui#8547 keys) | 4/4 | |
| negative control (the 77 dynamic-head members) | 0/77 | 68/77 flagged |
⇒ ⭐ the positive control ALONE cannot tell 62 from 267 — which is exactly what triage's criterion 4 predicted and demanded be demonstrated. And a "list nothing" implementation passes the negative and fails the positive 0/4. ⇒ both controls are load-bearing, ⛔ shown rather than asserted.
⭐ The dynamic-head leg is therefore not a refinement: without it the report is 267 lines long and, in triage's words, "一份每次都列出 226 个候选的报告,等于没有报告".
⭐⭐ The positive control fired on its OWN contamination — during development
A scratch probe left inside the repo, spelling the four keys, demoted all four to NEEDS-REVIEW. The reading only came back right once the probe left the scanned tree. ⇒ the new pin tests therefore use synthetic engine.fx.* keys and name no real table key.
8385 in its worktree path 56 times. ⇒ both are now in this seat's standing corrections, and both were found by the dev, on itself.
⭐ And a void reading that was discarded rather than reported
The first ablation attempt wrote nothing — perl -0pi failed to compile — and the occurrence check caught it. ⇒ those greens were discarded, ⛔ not reported as a passing leg. ⭐ That is the rule this seat wrote after a dev hit it earlier today, applied unprompted.
Corpus figures — corrected against the card
| card | measured at da5e4f69e |
|
|---|---|---|
| EN keys | 1660 | 1661 |
| ZH keys | 1715 | 1716 |
| no literal spelling in non-test source | 226 | 221 |
| zh-only (declared deliberate) | 55 | 55 ✅ |
⇒ ⛔ small, and reported rather than smoothed over. The zh-only delta is explained, as triage required.
What this seat re-measured
| claim | reading | verdict |
|---|---|---|
| PR objectui#8687 is open, not merged ⇒ the positive control keeps its subject | ✅ | |
| the four keys still exist | each returns exactly 2 hits in i18n.ts — its own two definition lines |
✅ |
| ⭐ and that is a reading | lit control on the same instrument: engine.directory.quickFind appears in 3 files under packages/ (the table plus two readers) |
✅ |
| scope: Part 2 only | 3 files — the script, its test, one changeset. ⛔ The four dead keys are not in the diff | ✅ |
| the out-of-scope finding filed bare | objectui#8701 — ⛔ no labels, ⛔ no assignees | ✅ |
⭐ It refuses nothing — and the one non-zero exit is a vacuity guard on the report itself
"the only non-zero exit is a COLLAPSED scan, because a run with no dynamic heads does not look broken, it looks like a longer and more confident report."
⇒ ⭐ the sweep cannot red the tree (the ruled shape), but it can fail if it stops measuring — which is the exact defect class this lane keeps carding, closed inside the instrument on its first day.
⭐ And the reason it did NOT fix objectui#8701 is better than the finding
textFootprint()'s full-key probe is a plain substring test, so 13 pack keys are demoted on evidence about a longer sibling key. ⛔ Deliberately unfixed: objectui#4658's ruling rests on a false-positive rate measured with the current behaviour, so the new keyBoundary option defaults OFF and the pack half's output is unchanged (114 CONFIRMED / 247 NEEDS-REVIEW, before and after).
⇒ ⭐ changing it would silently move the ground a standing ruling stands on. Filing it is right; fixing it here would not have been.
Next
ci-wait on 90ce389f5, then flip, post-flip guard, arm SQUASH, enqueue from the timeline event.
Landing probe, scoped now: on a re-fetched origin/main, scripts/check-i18n-dead-keys.mjs carries the second corpus and the dynamic-head leg; its test file carries the new pins and ⭐ names no real table key (the contamination lesson); the pack half's tiering is untouched; ⭐ firing control — the four objectui#8547 keys still resolve to exactly their two definition lines each, with engine.directory.quickFind returning its readers in the same run; nonsense control exits 1.
Generated by Claude Code
Armed — objectui#8699, 2026-09-08T23:30Z
Landing probe, restated before the merge:
Generated by Claude Code |
Queue status at 2026-09-09T00:35Z — ⛔ this PR's delay is not its own diffEnqueued at 23:30Z, still unmerged 65 minutes later. Establishing why, so the next reader does not re-derive it:
Control: this PR's 3 files touch nothing under ⇒ nothing to fix here and ⛔ nothing to re-run — a re-run buys one more coin flip and another ~50 minutes of shared queue time. This PR stays enqueued. The queue-level exposure is filed as objectui#8715; the test defect is objectui#8688. Generated by Claude Code |
⭐ LANDED — objectui#8388 / PR #8699, merged
|
| leg | reading | verdict |
|---|---|---|
| second corpus + dynamic-head leg | :531 ## The SECOND corpus this file sweeps; DESIGNER_TABLE :605, DESIGNER_TABLE_CONSTS :610, DESIGNER_KEY_ROOTS :615; the dynamic-head leg at :555 with collectDesignerReaders() building dynamicHeads and a vacuity floor at :860 (corpusSize < 1000 || literalReferencedCount < 500 || dynamicHeads.size === 0) |
✅ |
| ⭐ the new pins name no real table key | every designer-shaped literal in the test file is under the fictitious *.fx.* namespace — engine.fx.dead, engine.fx.spelled, engine.fx.zhOnly, engine.fx.prefix, engine.fx.builtHead.alpha, engine.fx.quotedInDoc, engine.fx.mentionedInTest, designer.fx.dead. Each checked individually against the real table: none exists in it |
✅ the contamination lesson held |
| pack half's tiering untouched | ⭐ established structurally, see below | ✅ |
| ⭐ firing control | ✅ on the substituted live half | |
| nonsense control | two of them exit 1, each with a firing control on the same channel (16 hits / 2 hits) | ✅ |
⭐ The pack half: proved by construction, not by a re-run
The Armed note framed this as "tiering untouched (114 CONFIRMED / 247 NEEDS-REVIEW)". I did not re-run the census, and I am saying so rather than reporting a number I did not take — a fresh count would in any case be measured on a tree that has moved five merges since. What I established instead is strictly stronger than a numeric match:
All 12 removed lines in the diff, accounted for individually — 5 are doc-comment prose, 2 are imports replaced by supersets, and the remaining 5 are:
| removed | replaced by | default |
|---|---|---|
export function textFootprint(root, keys) { |
…(root, keys, options = {}) |
— |
chain: propertyChainProbe(key) |
chain: propertyChain ? propertyChainProbe(key) : null |
propertyChain = true |
if (relFile.startsWith(LOCALES_DIR)) continue; |
if (relFile.startsWith(definitionPrefix)) continue; |
definitionPrefix = LOCALES_DIR |
const literal = content.includes(key); |
const literal = keyBoundary ? occursAtKeyBoundary(…) : content.includes(key); |
keyBoundary = false |
if (strict && result.confirmed.length > 0) |
if (strict && (result.confirmed.length > 0 || designer.confirmed.length > 0)) |
pack arm unchanged, designer arm added |
⇒ every default reproduces the pre-merge behaviour exactly, and the pack half's only call site — :500, textFootprint(root, candidates) — passes no options object at all (the designer half's is :803, which does). Nothing else was removed or altered; every other change in the +489 is pure addition. ⭐ So the pack tiering is unchanged by construction, for any tree, which a matching pair of numbers on today's tree would not have shown.
⚠️ The pre-written firing control was invalidated between arming and merging
The note's control was "the four objectui#8547 keys still resolve to exactly their two definition lines each." On current origin/main all four read 0:
engine.directory.allPackages -> 0 engine.list.allPackages -> 0
engine.directory.packageFilter -> 0 engine.list.packageFilter -> 0
⛔ That is not a probe failure and ⛔ not a bare zero to report as a reading. objectui#8687 deleted those exact four keys at 22:45Z (de1af552a, "delete four dead ENGINE_STRINGS keys", carrying .changeset/retire-dead-engine-strings-8547.md) — i.e. the card this control named as a live fixture was implemented by another seat in the 75 minutes between my arming this probe at 23:30Z and the merge at 00:57Z.
Substituting the control's own live half, and it fires:
i18n.ts:187 'engine.directory.quickFind': 'Quick Find'
i18n.ts:2123 'engine.directory.quickFind': '快速查找'
readers, same run: DirectoryPage.tsx, StudioHomePage.tsx
⇒ exactly two definition lines, EN and ZH, with its readers returned alongside — the shape the control existed to demonstrate. The instrument fires; the four zeros above are correct readings of keys that are genuinely gone.
⭐ Worth inheriting as a trap rather than a one-off: a probe written before the merge can have its control retired by an unrelated PR that lands in between. A pre-written control is a hypothesis about the tree at merge time, not a constant — re-establish that it still fires before reading anything off its zero.
Generated by Claude Code
⛔ Correction to my queue-status note above — the mechanism I gave was wrong
PR #8683 ( The ⭐ My method error, since it is the reusable part: GraphQL Full account on objectui#7010 ( Generated by Claude Code |
Fixes #8388
Part 2 only, per triage's split (
5582360339). ⛔ The four dead keys of objectui#8547 are not touched here — they are objectui#8687's, still open, and this PR uses them as its positive control precisely because they are still in the tree.The shape is triage's ruling, not a choice made here: option 1, a report-only sweep plus a dynamic-head leg. Option 2 (per-namespace closed worlds) stays available later per namespace and is not in scope. Nothing in this diff can red the tree over a dead key.
What landed
scripts/check-i18n-dead-keys.mjsgains a second corpus:ENGINE_STRINGS_EN/ENGINE_STRINGS_ZHinpackages/app-shell/src/views/metadata-admin/i18n.ts. One script, because it asks the same mirror question — does a key the table DEFINES have any reader at all? — from the same side. Not one walker: the pack half reuses the call-site gate's AST pass, and this half needs its own, because no call-site walker in this repo can see this table.That is measured, not assumed. Both repo-wide i18n gates return exit 0 over this table and neither verdict is about it:
check-i18n-call-site-keys.mjswalks call sites and resolves each against theenpack. A key no call site names is not a subject to it, so it can never report a dead key — the defect and its blind spot are the same thing. It also classifies this modulemodule-local tableby declaration and skips it.check-i18n-en-drift.mjsreads the ten locale packs. This table is not one of them.Three legs, and the second is the load-bearing one:
packages/+apps/source starting withengine./designer./perm.. Deliberately not restricted to arguments oft()/tOptional()/tFormat(): measured on this tree, 31 call sites park a key in a config property (labelKey: 'engine.studio.rules.typeScript') and hand it to a translator through a variable. An argument-only walk would report every one of those keys dead.translateNodeLabel/translateEnumOptionindexpickTable(locale).strings[...]directly with not()call anywhere near them.The four acceptance criteria, each with its measured outcome
All readings at head
90ce389f5, on a clean tree, fromnode scripts/check-i18n-dead-keys.mjs(exit 0, empty stderr).1. A candidate list short enough that a person will read it — 62, not 226
EN1661,ZH1716, 55 zh-only, 0 en-only)Grouped by namespace, that is one screen:
engine.inspector22,engine.edit12,engine.studio12, then eight namespaces of 1–3.Six CONFIRMED entries were sample-verified by hand (objectui#4658's standing requirement). All six greps returned only longer sibling keys, never the candidate — e.g. hunting
engine.edit.inspectorreturnst('engine.edit.inspector.properties')andt('engine.edit.inspector.source'), a different key each time. 0 false positives in the sample.2. Positive control — the four known dead keys ARE listed
engine.fx.*keys and ⛔ name no real table key: a test that spells a candidate silently demotes it in the report those tests exist to keep honest.3. Negative control — none of the seven template-head classes is listed
memberscounts table keys under the class;held-aloneis how many of them only the head keeps out of the list.engine.list.source.VALUEpasses for a different reason than the other six —held-alone=0, because all four of its members are also spelled as literals elsewhere. It is excluded by leg 1, not by leg 2. Recorded rather than smoothed over: the report prints that zero, and its own text says a row falling to zero means the family stopped being template-built (or the extractor drifted), never that the sweep got better.4. Both controls run, because each alone is passed by a degenerate implementation
This is the criterion that needed an actual experiment, so it got one — the dynamic-head leg was ablated on disk (
const heads = [...]emptied; mutation proven landed by occurrence count before reading anything, restored blob hash compared againstHEAD,git diff HEADempty after):⇒ the positive control alone cannot tell the shipped instrument from the ablated one; the negative control alone cannot tell it from an empty one. Only both together pin it, exactly as triage argued.
Two pin tests red under that ablation (
the negative control,reads template heads inside the table module itself); two others red under a second ablation that removes the key-boundary check. Full ablation transcript in the issue report.Today's corpus figures, and the zh-only delta
Measured at
da5e4f69e(this branch's base). The card measured1660 EN / 1715 ZH / 226 / 0 in pageBlockat34c22ad39; today:ENGINE_STRINGS_ENENGINE_STRINGS_ZHengine.flowNode.*— the EN table deliberately carries none, sotranslateNodeLabelfalls through to the server descriptor's ownname. (card ✅)engine.packages.form.help.*— objectui#5416'stOptionalhelp text, where en-US finds nothing and falls through to@objectstack/spec's own.describe(). (card ✅)engine.enum.type.*—translateEnumOptionreturns the raw value unchanged for any non-zh locale before it ever reads the table, so an EN row could not be reached. Same design, third instance.Each of the three is a helper that returns early or falls back for
enby construction, so en-only is 0 and must stay 0 — an EN key with no ZH twin would be an unlocalised string, and the sweep prints that count every run.What this sweep does NOT see
Stated because a report-only instrument that overstates its reach is worse than none (objectui#8068).
t(SOMEVARIABLE)call sites exist anywhere underpackages/app-shell/src/views/— but neither is a guarantee.examples/ande2e/are outside the AST walk (matching the call-site gate's own scope, so the two directions stay comparable). Measured: zero occurrences of anyengine./designer./perm.key in either directory today, so the gap is provably empty right now — a gap, not a guarantee. The text net still reaches them, but it only ever demotes to NEEDS-REVIEW; it never establishes liveness.engine.studio.saveDraft, the single entry in that tier, sits.zh-only asymmetry is not judged. The sweep answers membership, not coverage: a key present in both tables whose ZH value is stale, or whose EN value is wrong, reads as perfectly live here. That ischeck:i18n-drift's question, and it does not reach this table either.keyBoundarydefaults to off, so the ten-pack report reads identically to before this PR (114 CONFIRMED / 247 NEEDS-REVIEW, same as on the base). The pack half has the same prefix-substring artefact, and correcting it there would silently re-tier a report whose false-positive rate was measured with the old behaviour — so it is left alone and filed separately rather than changed as a rider.Not enforcing, and the one thing that does exit non-zero
--strictstays unwired; no workflow runs this script, and a pin test asserts that. There is deliberately no ratchet on the candidate count and no test naming a real key — either would turn a report-only instrument into an enforcing one through the back door, and the four keys the positive control uses are themselves scheduled for deletion by objectui#8547.The one non-zero exit is a collapsed scan: a run whose corpus, literal-reference count or dynamic-head set comes back implausibly small. That failure does not look broken — it looks like a longer and more confident report, which is the whole failure mode this leg exists to avoid. A head too wide to apply (a bare
engine., which would mark 1300+ keys live) is reported on stderr and not applied.Verification
node scripts/check-i18n-dead-keys.mjspnpm exec vitest run scripts/pnpm exec vitest run scripts/__tests__/check-i18n-dead-keys.test.tspnpm run type-check:scriptstsc -p tsconfig.scripts.json)pnpm exec eslint scripts/check-i18n-dead-keys.mjs scripts/__tests__/check-i18n-dead-keys.test.tsnode scripts/check-control-bytes.mjsnode scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsmajorbump."node scripts/check-governed-queue-guard.mjs --test [the 3 paths]Every exit code captured before any pipe. The changeset is the empty-frontmatter form:
check-changeset-presence.mjssays none is owed at all (tooling and its tests, no published package source), and the empty declaration is the honest way to say "releases nothing" rather than leaving the question unasked.Draft, as dispatched. ⛔ No auto-merge.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code