fix(cli): count one translation key once in the i18n coverage population - #15368
Conversation
… walk Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…port Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…dings Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 79f7e150df8dddc0a947045d70767d96465525f1 && git checkout 79f7e150df8dddc0a947045d70767d96465525f1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc46c4ec133eb946c50805a851e4f6218058f366 ef3b7f120c7c8bbc00224977f1ffeb9880426887 && git checkout -B drift-repro dc46c4ec133eb946c50805a851e4f6218058f366 && git merge --no-ff ef3b7f120c7c8bbc00224977f1ffeb9880426887
node scripts/docs-audit/affected-docs.mjs --json dc46c4ec133eb946c50805a851e4f6218058f366 |
Fixes #14728
One translation key is one demand
A translation key is derived from where a string is addressed, not from which declaration was being read when the walk reached it.
collectExpectedEntriesemitted one entry per declaration, so a key two declarations both address became two expected entries — andcountI18nRuleIssuestakes.lengthover thei18n/findings while its report calls the number "untranslated declared strings". Translating one key moved the ratchet by two.The walker now collapses entries that address the same path, keeping the first emission. The ratchet's direction, monotonicity and failure text are untouched — only the population it counts.
Re-derived, not reused
The card said its numbers were on a tree many merges old and must be re-measured. They were, with the card's own repro, on
2ed6be64before the fix, and re-confirmed after it on the merged tree atef3b7f12:examples/app-todo/objectstack.config.tsexamples/app-showcase/objectstack.config.tsexamples/app-crm/objectstack.config.tsTotal 691 → 621, exactly −70. The card's 40 / 29 / 1 survived unchanged on today's tree. The baseline was regenerated with
node scripts/check-i18n-coverage.mjs --update, never hand-edited; the gate reported the DOWN direction on all three configs first, which is the sanctioned re-derive.After the fix,
extra(findings beyond the first occurrence of a path) is 0 on all thirteen configs.The measurement that chose the seam
The card was neutral between de-duplicating at production (
collectExpectedEntries) and at the reporting seam (i18n-coverage.ts). Three readings decided it for production.1. No consumer needs the per-carrier multiplicity.
collectExpectedEntrieshas three non-test consumers:computeI18nCoverage(one finding per key + locale),extractTranslations(writes throughsetDeep, which collapses them anyway), andcheck-i18n-walk-parity.mjs(a set ofpath[0]). None reads a count that the duplicates make true.2. A second consumer was lying too, and the reporting seam does not reach it.
extractTranslationscounted emissions intotalExpectedand in the per-localecountsit prints, while the skeleton had already collapsed the duplicates on the way in. Measured before → after:counts.enreported3. The duplicates are NOT confined to
_actions— that is a finding, and it is the deciding one. The card predicted every duplicate would be inobjects.OBJECT._actions.ACTION.*. In the report that holds: 40 / 29 / 1, all_actions. At the walker there is a second family the report never sees:obj.actionsand to the top-levelactionslist. Probed on all three configs:sameRef=true— literally the same JavaScript object, not a copy, so the two emissions are byte-identical by construction. todo 8/8 actions in both carriers, showcase 69/70, crm 1/1.deleteBehavioris declared twice in each ofpackages/spec/src/data/field.form.tsandobject.form.ts, gated onvisibleWhen(lookupvsmaster_detail), andwalkFormFieldkeys on the field path — so both variants render into one key. This is config-independent: it duplicates six entries on an empty config. It never reachesos lint's report because themetadataFormsbucket is hidden unless--include-platformis passed.Neither is an authoring mistake; both are two correct declarations of one displayed string, and neither is fixable where it originates. A de-duplication at the reporting seam would have fixed the family that happens to be visible today and left the registry-driven one duplicated in perpetuity — inflating
os i18n extractforever, and re-surfacing the moment--include-platformor a ledger change made those keys reportable.First emission wins, and it is lossless on everything measured: all 372 duplicate paths across the three baselined configs carry byte-identical
ExpectedEntryrecords (372/372 — 354 in the action family, plus the 6 registry ones that repeat on every config). Where two emissions ever do disagree, one bundle slot can serve only one string, so the choice is which colliding declaration to seed from — never whether to drop a demand. The rule is documented ondedupeByPathand pinned.The pin
packages/cli/test/i18n-duplicate-demand.test.ts, 7 assertions, and it pins the property, not the counts — a pin on 621 or on 101 / 414 / 106 would go green again the day a third carrier joins the walk. The card's property is asserted in the spellingos lint --jsonuses (translations.LOCALE.KEY, percommands/lint.ts:66): no report carries two findings with the same path for the same locale, both on a dual-carrier config and over the platform bucket the reporting seam could not see. Two walker-level pins assert each path is emitted at most once (once on a dual-carrier action, once on a config that declares nothing, which is the registry family). Two more pin what de-duplication must not do: the collapsed action's keys are all still emitted, and the first emission wins on disagreement.Ablation (from the committed state; mutation confirmed on disk by counting the injected and deleted anchors, restore confirmed by
git hash-objectequality to the HEAD blob and an emptygit diff HEAD): replacingreturn dedupeByPath(walked)withreturn walkedturns 5 of the 7 red — both walker pins, the extract-count pin, and both report pins. The two that stay green are the two that assert presence and precedence rather than uniqueness, which is the correct behaviour for them. The pin resolves the walker throughsrc/, so the flip needed no rebuild — which is itself the evidence that the mutation reached the subject under test.Verification
Everything below was run on the merged tree at
ef3b7f12(mainmerged in;git status --porcelainempty), afterpnpm --workspace-concurrency=2 build. Each exit code was captured by redirecting first and reading$?before any pipe, and each gate is quoted by its own verdict line rather than by a bare$?.Gate union — derived, never hand-listed:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson a clean tree atef3b7f12, which reported the change set as exactly the 4 paths this PR touches. 51 of 51 commands run, 0 non-zero.No gate in the union failed, and none was skipped.
The ones this change is actually about:
Package suite and types
The typecheck covers the new pin rather than skipping it: this package uses the sibling
tsconfig.test.jsonpattern, so the test layer is compiled by a program that really reads it.Two gate classes are NOT MEASURED here and are named rather than counted as passes: the 13 families
dispatch-gatesreports as taking a value from the workflow (their argv carries a variable that has no value outside a CI run), and the 34 artifact-roster families it scoressilentfor every card in the tree. Their silence is not a clearance, and CI runs the farm regardless.No generated bundle moved:
node scripts/check-i18n-bundles.mjsreports all nine packages in sync andgit status --porcelainis empty after it — de-duplication removes copies, never demands.Scope
Out of scope and deliberately untouched: the ratchet mechanism in
scripts/check-i18n-coverage.mjs(the defect is the population, not the gate), and the three sibling_actions-walk cards (#14653, #14700, #14708) whose fix sites are elsewhere.Generated by Claude Code