Skip to content

fix(scripts): report every dynamic head the i18n dead-key pack sweep applies - #9312

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-9126-pack-dynamic-head-report
Sep 12, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-9126-pack-dynamic-head-report

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Part of #9126

The pack half of check-i18n-dead-keys.mjs applies every dynamic template head it collects, unfiltered, and reported nothing about it. Its sibling corpus in the same file 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 neither filtered nor reported, so a key held live merely by starting with some head was indistinguishable from a key nothing reaches — and the candidate count read as evidence of cleanliness.

This PR changes no verdict. It makes the pack half say what it is already doing.

⛔ The fences, and what was done instead

Triage's fence What this PR does
⛔ No filtering in the pack half, at any depth appliedHeads is computed after candidates, from the same unfiltered head list. Nothing feeds back.
⛔ Do not adopt the designer half's MIN_HEAD_SEGMENTS Not adopted. ownSegments is a printed column, never a predicate. The threshold stays the maintainer's call.
⛔ No key deleted, re-tiered or retired The confirmed / needsReview lists are byte-identical before and after.
⛔ Do not claim the three heads are wrong The report says each head comes from a real call site building a key from a runtime value, and that the row measures its reach, not its correctness.
⛔ Do not "also fix" the designer half Untouched. Its output is byte-identical.

⭐ The lit control — the candidate set did not move

Both runs are node scripts/check-i18n-dead-keys.mjs --json on this branch's tip, before and after the change:

candidateCount   before 358 after 358 -> UNCHANGED
totalPackKeys    before 3006 after 3006
referencedKeys   before 15778 after 15778
confirmed list   identical: True ( 119 )
needsReview list identical: True ( 239 )
designer candidateCount   before 58 after 58
designer wideHeads before [] after []
new top-level JSON keys: ['appliedDynamicHeads'] | removed: []
every other top-level field identical: True

⚠️ The card says 365; today's tip says 358. Re-derived, not inherited.

The card's reading is two days old and the file has moved twice. Rather than tune anything, the card's own tree was re-run: a throwaway worktree at bef003c243 (the tip of main at the card's filing minute, 2026-09-11T08:01:36Z) reproduces the card exactly365 candidates across 47 namespaces. Diffing that candidate set against today's:

365 − 8 + 1 = 358. ⇒ the lit control for this PR is 358 → 358, and the card's 365 is confirmed as a correct reading of the tree it was taken on.

⭐ The head table, re-derived on today's tip

The card's three measured heads match to the key:

head card this run (under)
approvalsInbox. 169 169
common. 48 48
managedByBadge. 15 15

The card's second column ("of those, with no literal call site at all" — 164 / 17 / 15, summing to 196) reproduces exactly as the new held column.

The new output, verbatim

dynamic template heads APPLIED to the pack corpus — all 28, none filtered

"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 358 candidate(s) is a reading of the pack MINUS 375 key(s) across 28 head(s) (417 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:

  head                                     own under  held   via
  approvalsInbox.                            1   169   164   direct
  common.                                    1    48    17   direct
  chatbot.tool.                              2    35    35   direct
  filterBuilder.operators.                   2    22    22   direct
  managedByBadge.                            1    15    15   direct
  marketplace.category.                      2    15    15   direct+indirect
  dashboard.filters.range.                   3    13    13   direct
  capability.label.                          2     9     9   direct
  report.aggregate.                          2     7     1   direct
  appDesigner.fieldDesigner.typeCategory.    3     6     6   direct
  console.settingsHub.categories.            3     6     6   direct
  dashboard.trend.                           2     6     6   direct
  home.recentApps.itemType.                  3     6     6   direct
  console.ai.group.                          3     5     5   direct
  gantt.viewMode.                            2     5     5   direct
  grid.import.jobStatus.                     3     5     5   direct
  grid.import.type.                          3     5     5   direct
  organization.invitations.status.           3     5     5   direct
  console.identityImport.policy.             3     4     4   direct
  console.identityImport.policyHint.         3     4     4   direct
  gantt.link.rejected.                       3     4     4   direct
  gantt.linkType.                            2     4     4   direct
  search.badge                               2     4     4   indirect
  search.type                                2     4     4   indirect
  capability.group.                          2     3     3   direct
  grid.import.confidence.                    3     3     3   direct
  marketplace.disclosure.runtime.            3     3     3   direct
  gantt.linkEnd.                             2     2     2   direct

  ⚠️ 3 of those head(s) name a top-level namespace and nothing else (approvalsInbox., common., managedByBadge.), together reaching 232 leaf/leaves and holding 196 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.

Ablation — the negative control can actually fail

Turning the new ownSegments column into a filter (the exact thing the fences forbid) was injected on disk, measured, and restored:

--- pre-mutation anchor counts ---
deleted-text count: 1
injected-text count: 0
--- post-mutation on-disk proof ---
ABLATION-9126 marker count on disk: 1
unfiltered original line count on disk: 0
1615:    .filter((head) => head.replace(/\.$/, '').split('.').length >= 2); // ABLATION-9126
--- candidate count under the mutation ---
MUTATED candidateCount: 554
MUTATED appliedHead rows: 25
--- the negative-control tests under the mutation ---
VITEST-EXIT=1
     × reports every head it applied, including one naming only a top-level namespace
     × counts the head's own segments, so a namespace-wide head is visible AS one
     × ⛔ NEGATIVE CONTROL: the namespace-wide head is still APPLIED — no key under it is a candidate
     × counts every leaf UNDER the head, whether or not another leg already holds it
     × counts as HELD only the leaves no other leg keeps live
     × marks where each head came from
     × reports one row per head the sweep actually applied — no head goes unreported
     × ⛔ does NOT adopt the designer half's threshold for the packs
 Test Files  1 failed (1)
      Tests  8 failed | 125 passed (133)
RESTORED ok: blob dd6a9de783ac29243404e5cf04fa0564fc691706 matches HEAD

554 − 358 = 196 — the mutation's cost is exactly the card's headline figure, arriving independently. The restore leg is git checkout HEAD -- PATH and is proven by an empty git diff HEAD and a blob-hash match against HEAD, not by an exit code; the script carries a trap … EXIT INT TERM so a kill mid-mutation still restores.

Gates

gate exit verdict line
scripts/__tests__/check-i18n-dead-keys.test.ts 0 Tests 133 passed (133) (was 122; +11)
the 3 sibling suites that reference this script 0 Tests 187 passed (187)
pnpm check:i18n-dead-keys 0 full report, tiers unchanged
pnpm check:i18n-designer-parity 0
pnpm check:control-bytes 0 ✅ check-control-bytes: OK (scanned 7504 tracked text file(s); skipped 85 binary).
pnpm check:changeset-claims 0 report-only
pnpm check:new-line-citations 0 VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0
node scripts/check-changeset-presence.mjs 0 ✅ No source or published contract of a released package changed in this range, so no changeset is owed.

eslintlint:root scope run at final commit d1ce8e9f85, --no-inline-config --format json: population 341 files (read from eslint's own file resolution, not guessed), of which both changed files are present with 0 errors, 0 warnings. The single error in the population is pre-existing no-console at e2e/live/global-setup.ts:47, in a file this diff does not touch. Not a narrowed run — this is the whole root scope.

A changeset with empty frontmatter is included: scripts/-only, nothing published moves.

维护者速读(草稿)

改了什么check-i18n-dead-keys.mjs 的 pack 半边现在会打印它应用了哪些 dynamic template head:每个 head 自身有几段、底下有多少 en leaf、其中多少是这个 head 独自按住(没有任何其它 leg 已经保活)的。--json 多一个 appliedDynamicHeads 字段。另加一行,点名那些只命名了一个顶层 namespace 的 head。

为什么改 — 同一个文件的 designer 半边遇到这类 head 会拒绝并响亮报错;pack 半边既不过滤也不报告。于是"358 个候选"读起来像一份很干净的清单,而读者无从得知另有 375 个 key 压根没被提出来,其中 196 个由三个 head 独自按住。本 PR 只把沉默变成读数。

风险与代价(含回滚) — 风险接近零:候选集、两个 tier、designer 半边的输出逐字节不变(上面 lit control 有对照)。这是纯增量的打印 + 一个新的返回字段。回滚 = revert 这一个 commit,没有数据迁移、没有 key 变动、没有门禁语义变化。唯一的代价是报告变长了约 35 行。

席位意见 — (留空,待席位定稿)

你要做的 — ⭐ 门槛仍然是你的。本 PR 刻意为 packs 选定 MIN_HEAD_SEGMENTS 那类数字;卡里说 pack namespace 形态与 designer 不同,那个数字归维护者。现在你手上第一次有了做这个决定所需的读数:三个 head(approvalsInbox. / common. / managedByBadge.)覆盖 232 个 leaf、独自按住 196 个 key。要不要收窄、收窄到几段,请开一张新卡裁决 —— ⛔ 不要在本 PR 里改。


Generated by Claude Code

…applies

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
@github-actions github-actions Bot added the tests label Sep 12, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 12, 2026 17:37
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — reviewed against the DIFF. Flipped ready; ⛔ not armed. Probe below, posted BEFORE any merge.

domain:devx @ objectui PM seat (session_01FhBNJcLRZLe8M87VcUgpKr), R55, 2026-09-12T17:4xZ. Head d1ce8e9f85, base 9ae871d004. Three files, +242 / −0 — ⭐ zero deletions, which is itself consistent with a reporting-only change.

⭐ The acceptance criterion fired, and the round answered it the right way

Triage's criterion 1 required the re-derived heads to match 169 / 48 / 15, and criterion 2 required the candidate count to be unchanged at 365. On today's tip the candidate count is 358, not 365. ⛔ The round did not tune anything to match, and ⛔ did not quietly adopt its own number. It explained the discrepancy before touching code, by re-running the card's own tree at bef003c243main's tip at the card's filing minute — which reports 365 / 47 namespaces, confirming the card was right when written. The delta is then accounted for exactly:

  • −8search.badge* / search.type*, now subtracted by the one-hop indirect-template leg from c4a1d39fe9 (PR objectui#9222). That family is the whole search bucket, hence 47 → 46 namespaces.
  • +1kanban.columns, which lost its t() call site in 3dd533f1d6 (PR objectui#9169).

365 − 8 + 1 = 358. ⇒ the lit control for this card is 358 → 358, and it held: candidate count unchanged, confirmed (119) and needsReview (239) lists byte-identical, designer candidate count 58 → 58, every other top-level field identical, exactly one new key (appliedDynamicHeads) and none removed.

⭐ This is the criterion working as designed. A stale acceptance number is a prediction, and the correct response to a mismatch is to re-derive the prediction's own tree — ⛔ not to move the code until it agrees.

⭐ The head table reproduces exactly

head card under re-derived card "no literal call site" re-derived held
approvalsInbox. 169 169 164 164
common. 48 48 17 17
managedByBadge. 15 15 15 15

Held still sums to 196. Nothing tuned.

The central fence, verified in the file rather than in the report

⛔ 「Do not add filtering to the pack half」 is the one way this card could have gone wrong. The new code does contain a filter on the new ownSegments column — so I checked every use of it:

2284:      const rootOnly = rows.filter((row) => row.ownSegments < 2);
2285-2286: reduce(...) → rootOnlyUnder / rootOnlyHeld
2306:      if (rootOnly.length > 0) {
2308-2310: console.log(`  ⚠️ ${rootOnly.length} of those head(s) name a top-level namespace …`)

rootOnly is referenced only inside the console.log block. It shapes the warning line and nothing else; it never reaches the candidate set or the applied-head set. ⭐ ownSegments is a printed column, ⛔ never a predicate on what is applied.

⭐ The ablation reproduced the card's headline figure independently

Injecting the forbidden filter on ownSegments (with on-disk proof of the mutation, restore proven by an empty git diff HEAD and a blob-hash match rather than an exit code) moved the candidate count 358 → 554 and reddened 8 tests, including ⛔ NEGATIVE CONTROL: the namespace-wide head is still APPLIED. ⇒ 554 − 358 = 196 — the card's own headline number arriving from the opposite direction, in a run nobody designed to produce it. That is the strongest evidence in the round that the reporting measures what it claims.

⭐ Pre-merge probe — counted on base 9ae871d004, code-only reader, comment lines excluded

leg base head role
appliedHeads 0 4 must ARRIVE
ownSegments 0 3 must ARRIVE
MIN_HEAD_SEGMENTS 2 2 must STAY — the designer threshold was ⛔ NOT adopted for the packs
NOT applied 1 1 must STAY — the designer half's loud refusal line is untouched
headHeldCounts 4 4 must STAY — designer half untouched
wideHeads 8 8 must STAY — designer half untouched

⭐ Four of the six legs are must-STAY, and that is the right shape for this card: the deliverable is visibility only, so almost everything interesting is what must NOT have moved. A probe weighted toward arrivals would have passed happily on a diff that also "tidied" the designer half — which triage explicitly forbade.

State

Changeset: A (new file), genuine empty frontmatter — ⛔ not a patch bump, and the pre-existing .changeset/*.md files are untouched. Gates all exit 0, tests 122 → 133.

Flipped draft → ready. ⛔ Not armed — the flip adds Governed Surface Queue Guard, so pre-flip green is stale; CI is re-read on d1ce8e9f85 after the flip and every check must be green, ⛔ not the required subset. ⚠️ errata 62b: legs are counted on 9ae871d004; ⛔ re-confirm against the ACTUAL merge parent before reading the probe as passed.

⚠️ Noted, not actioned: the commit message carries Part of #9126 — a non-closing reference, so it cannot close the card, and correcting it would require an amend, which is forbidden on a pushed branch. ⛔ Left as-is deliberately.


Generated by Claude Code

@baozhoutao
baozhoutao added this pull request to the merge queue Sep 12, 2026
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

ARMED — in the merge queue at 17:51:30Z. ⛔ Legs frozen as posted before arming.

domain:devx @ objectui PM seat (session_01FhBNJcLRZLe8M87VcUgpKr), R55. Head d1ce8e9f85.

Green re-read AFTER the flip, waiting for every check rather than the required subset: ALLGREEN at 17:51:24Z, 0 failing, 0 pending. (ready_for_review 17:37:48Z.)

Queue entry verified by the EVENTenable_pr_auto_merge returned its degenerate form again (method: , enabled at , both fields empty), so it was ⛔ not read as a signal:

timeline: added_to_merge_queue    2026-09-12T17:51:30Z

errata 62b — clean, no stacking. Queue ref gh-readonly-queue/main/pr-9312-9ae871d0044b37ca5cd8616096cd1c73a7898e7d: the entry's base is 9ae871d004, which is both current main and exactly the base the legs were counted on. ⛔ Still re-confirmed against the real merge parent at landing — an entry can be re-based underneath you, as PR objectui#9141's was three times today.

The probe (#issuecomment-5647568315) runs verbatim on <merge>^ <merge>: appliedHeads 0→4 and ownSegments 0→3 must ARRIVE; ⭐ MIN_HEAD_SEGMENTS 2→2, NOT applied 1→1, headHeldCounts 4→4, wideHeads 8→8 must all STAY — four must-STAY legs, because the deliverable is visibility only and what matters most is what did not move.


Generated by Claude Code

Merged via the queue into main with commit f13b2f1 Sep 12, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-9126-pack-dynamic-head-report branch September 12, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants