Skip to content

feat(gate): detect translation leaves stranded on a previous source revision - #12067

Merged
yinlianghui merged 4 commits into
mainfrom
claude/issue-11671-i18n-stale-source-leaf
Aug 25, 2026
Merged

feat(gate): detect translation leaves stranded on a previous source revision#12067
yinlianghui merged 4 commits into
mainfrom
claude/issue-11671-i18n-stale-source-leaf

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Part of #11671

Part of, not Fixes, deliberately: this ships the ruling's "at minimum" half. The durable half needs a bundle/extract format change, which triage ruled a fork to report rather than invent — the report is in the card's dev report and summarised at the bottom of this body. The card should stay open on that question.

The hole

os i18n extract --fill=default fills gaps only (packages/cli/src/utils/i18n-extract.ts, the merge branch):

if(opts.mergeExisting!==false&&locale!==defaultLocale){constexistingValue=lookupDeep(existing[locale],entry.path);if(existingValue!==undefined&&existingValue!=='')value=String(existingValue);}

Any non-empty existing value in a translated locale wins, forever. So the ordinary sequence — extract, revise the source string, extract again — rewrites en (never merged, #8543) and silently keeps the previous source text everywhere else. The bundle is still in sync by key, so check:i18n reports OK; the leaf is still present, so check:i18n-coverage counts it translated — it tests presence, not freshness (i18n-coverage.ts: if (value !== undefined) { translated += 1; continue; }). Measured on #11659 at bbe0b17: three locales serving a 602-char superseded draft of a 411-char help string, under 31 green checks.

Why the ruling's fallback could not be implemented as worded

"Flag untranslated leaves that no longer match the current source" describes an empty set. "Untranslated" has exactly one observable spelling today: EQUAL to the current source. Once the source is revised, a stale fill stops being equal to it and becomes indistinguishable by value from a real translation — the two predicates are complementary, not conjoinable.

Measured on this tree: 2648 of 3010 leaves differ from en in at least one locale. That set is essentially every correct translation in the repo, so the literal rule would flag 88% of all leaves.

What IS decidable without provenance

Cross-locale agreement. Two different target languages do not independently produce byte-identical prose — when they hold the same bytes, neither translated it; both were filled from the source. If those bytes are not the current source, they are a previous revision of it. That reaches the ruling's primary target ("leaves byte-equal to a PREVIOUS source revision") with no recorded provenance and no format change, because the agreement between the locales is the evidence.

Four conditions, each closing one measured false-positive class. None is a threshold — no length cutoff, no ratio, no score:

#conditionclosesleaves
1≥2 translated locales byte-identical at one path— the provenance proxy47
2shared value ≠ current enordinary --fill=default state (578 leaves), which check:i18n-coverage owns47
3≥1 agreeing locale script-disjoint from the value (a zh-CN/ja-JP leaf with no CJK)zh/ja legitimately coinciding on Han labels (更新, 成功, 所有者)11
4differs from en by more than ASCII casedeliberate token casing (csvCSV, apiAPI, webWeb)5

Evidence, both directions

Fires on constructed drift. Following the card's own recipe at the bundle level, in plugin-audit — the package the drift was caught in. A previous source revision was stranded in two locales, the mutation confirmed on disk against the exact text before the reading was taken, under an EXIT INT TERM restore trap:

injected text present zh=1 ja=1 (want 1 / 1)
removed text gone zh=0 ja=0 (want 0 / 0)
exit=1
check-i18n-stale-fill: 1 NEW stale-fill leaf/leaves
• …/plugin-audit/…/objects.generated.ts#sys_audit_log.description
locales : ja-JP, zh-CN
source : "Immutable audit trail for platform events"
serving : "Immutable audit trail of platform events, including readonly field changes"

Silent on a clean tree. Restore confirmed on disk and by git status --porcelain (empty), then re-read:

injected text gone zh=0 ja=0 (want 0 / 0)
original text back zh=1 ja=1 (want 1 / 1)
exit=0 :: check-i18n-stale-fill: OK (10 bundle set(s) — no new stale fills, 5 baselined).

--self-test drives 13 cases, including #11671's own measured shape (three locales sharing a superseded draft) and one case per false-positive class above.

Why a ratchet rather than a hard failure

The rule finds real drift on main today — 5 leaves, all genuine, none a false positive. Repairing bundles is expressly not this card (#11671 was re-routed to the tooling lane for that reason), and a gate that is red on arrival gets switched off. So the debt is frozen in scripts/i18n-stale-fill-baseline.json with a reason per entry, making the ledger a worklist rather than a silencer, and the build fails the moment it grows. Same shippable middle check-i18n-coverage and check-role-word take.

Those 5 are filed for repair as #12065 — one of them, sys_notification_subscription.fields.principal.help, is the exact leaf #10026 measured on 2026-08-19 and is still stale a week later under a green CI.

Placement

Runs in Lint & Repo Gates, not with its two i18n siblings in typecheck: it reads the committed bundles as text, so unlike check:i18n and check:i18n-coverage it needs no workspace build. Population comes from the extract configs' own documented --out=/--locales= (the same seam check:i18n reads), so a package landing tomorrow is gated tomorrow. Empty population is refused rather than returned (#4690/#10907), and every read is anchored to the script's own location.

scripts/pm/dispatch-gates.mjs gains the gate on the existing i18n bundle-package entry, so a card that revises a source string is told about it up front; its self-test gains a pin for the new gate and its count pin moves 2 → 3.

The fork, stated not invented

Cross-locale agreement needs two locales to have gone stale together. A leaf stranded in one locale — because the others were re-translated, or a package ships one translated locale — carries no evidence and is invisible here. Closing that needs recorded fill provenance, and the repo already has a ruled shape for it: packages/platform-objects/src/apps/translations/source-hash.ts implements maintainer ruling #8765 Option B.

⚠️ That module is scoped to the hand-authored sections and its note asserts the hole "cannot occur" in the generated sections because en is rewritten every run. That assertion is false, and #11671 is its counterexample: rewriting en catches drift in en, while the translated locales keep merge semantics and strand the old text there. Extending the sidecar to the generated bundles would close the whole class — but it makes the extractor emit a new companion file per locale, which is a format change. Reported, not invented.

Verification

Every family named by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 801bed9b2 (26 families, exit code captured before any pipe) — all green except one declared prerequisite, below. check:entry-guard went red first and was a real finding: the module exports its rules, so its dispatch had to go behind isEntrypoint(import.meta.url); importing the module now runs nothing.

Patch round — bare-root-worklist --self-test. After the first push, that self-test failed: the gate carries PACKAGES_DIR = 'packages', a bare root, and every gate that assembles a root must carry a recorded verdict. Recorded as REFUSE-UNSPELLABLE, joining check:i18n and check:i18n-coverage, which reach their population through the same findExtractConfigs walk. The walk is recursive across the root — that is not the question the verdicts ask; what it admits is a filename-and-segment test: 9 of 5185 tracked files (0.17%). Measured, not inherited: both star spellings of the real population collapse to the same malformed double-separator prefix, and hintCovers matched it against nothing when checked against a real config path, while the only spellings that cover anything collapse to the bare root — so a declaration is either a live hint over zero files or a hint over 5185 files to reach 9. Only the TRIAGE map moved (one hunk, 18 insertions, 0 deletions); the recogniser, the sweep and that self-test are untouched.

check:type-check-debt is NOT MEASURED, declared rather than guessed: its --re-measure half refuses on an unbuilt closure ("measuring now would not fail, it would silently measure a DIFFERENT WORLD"). Its self-test and the content half both pass — check-type-check-coverage: OK — 65/78 workspace packages type-checked — and this diff contains zero .ts files and zero packages/ paths, so it cannot move a ledger number; CI builds the closure before that step.

Repo tooling only — no published package source, root package.json is private: true — so skip-changeset rather than a changeset. eslint --no-inline-config covers 100% of this diff's lint-governed surface (0 findings); the non-JS paths come back File ignored because no matching configuration was supplied, and the config enables no type-aware linting (eslint.config.mjs: "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file"), so no untouched file's verdict can move.


Generated by Claude Code

…evision
`os i18n extract --fill=default` fills gaps only, so the ordinary sequence —
extract, revise the source string, extract again — rewrites `en` (never merged,
#8543) and silently keeps the previous source text in every translated locale.
The bundle stays in sync BY KEY, so `check:i18n` reports OK, and the leaf is
still present, so `check:i18n-coverage` counts it translated: it tests presence,
not freshness. Measured on PR #11659 at bbe0b17 — three locales serving a
602-char superseded draft of a 411-char help string under 31 green checks.
"Flag untranslated leaves that no longer match the current source" cannot be
implemented as stated: "untranslated" has exactly one observable spelling today,
EQUAL to the current source, so the two predicates are complementary rather than
conjoinable. Measured on this tree, 2648 of 3010 leaves differ from `en` — that
set is essentially every correct translation in the repo.
What IS decidable without provenance is cross-locale agreement: two different
target languages do not independently produce byte-identical prose, so when they
hold the same bytes neither translated it — both were filled from the source, and
if those bytes are not the CURRENT source they are a previous revision of it.
Three further conditions each close one measured false-positive class (47 -> 11
-> 5); none of them is a threshold.
Existing drift is frozen with a reason per entry, all five of it real and
pre-existing; NEW drift fails. Repairing those bundles is deliberately not this
change — #11671 was re-routed to the tooling lane for that reason.
Needs no workspace build (it reads the committed bundles as text), so it runs in
`Lint & Repo Gates` rather than with its two i18n siblings in `typecheck`.
Part of #11671
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
The module exports its rules so they can be imported; without the guard that
import runs the whole gate inside the importer and exits its process — the
silent-success direction scripts/invoked-as.mjs documents. check:entry-guard
caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
…-UNSPELLABLE
The new gate reaches its population through the same findExtractConfigs walk
check:i18n and check:i18n-coverage use, so it joins them rather than being
judged apart: the walk recurses across the root, but what it ADMITS is a
filename-and-segment test — 9 of 5185 tracked files (0.17%).
Measured rather than inherited: both star spellings of the real population
collapse to the same malformed double-separator prefix, and hintCovers matched
it against NOTHING when checked against a real config path; the only spellings
that cover anything collapse to the bare root. So a declaration is either a live
hint over zero files or a hint over 5185 files to reach 9 — and declaring a root
the gate does not read wholesale is the costlier error.
Only the TRIAGE map moves. The recogniser, the sweep and the self-test are
untouched.
Part of #11671
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@github-actionsgithub-actionsBot added ci/cd dependencies Pull requests that update a dependency file labels Aug 25, 2026
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 08:02
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 8450eeaAug 25, 2026
35 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-11671-i18n-stale-source-leaf branch August 25, 2026 08:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui@claude