Uh oh!
There was an error while loading. Please reload this page.
feat(gate): detect translation leaves stranded on a previous source revision - #12067
Merged
Conversation
…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
…8n-stale-source-leaf
…-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
yinlianghui
marked this pull request as ready for review
August 25, 2026 08:02
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #11671
Part of, notFixes, 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=defaultfills gaps only (packages/cli/src/utils/i18n-extract.ts, the merge branch):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, socheck:i18nreports OK; the leaf is still present, socheck:i18n-coveragecounts it translated — it tests presence, not freshness (i18n-coverage.ts:if (value !== undefined) { translated += 1; continue; }). Measured on #11659 atbbe0b17: 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
enin 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:
en--fill=defaultstate (578 leaves), whichcheck:i18n-coverageownszh-CN/ja-JPleaf with no CJK)enby more than ASCII casecsv→CSV,api→API,web→Web)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 anEXIT INT TERMrestore trap:Silent on a clean tree. Restore confirmed on disk and by
git status --porcelain(empty), then re-read:--self-testdrives 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
maintoday — 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 inscripts/i18n-stale-fill-baseline.jsonwith a reason per entry, making the ledger a worklist rather than a silencer, and the build fails the moment it grows. Same shippable middlecheck-i18n-coverageandcheck-role-wordtake.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 intypecheck: it reads the committed bundles as text, so unlikecheck:i18nandcheck:i18n-coverageit needs no workspace build. Population comes from the extract configs' own documented--out=/--locales=(the same seamcheck:i18nreads), 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.mjsgains 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.tsimplements maintainer ruling #8765 Option B.enis rewritten every run. That assertion is false, and #11671 is its counterexample: rewritingencatches drift inen, 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/objectstackat801bed9b2(26 families, exit code captured before any pipe) — all green except one declared prerequisite, below.check:entry-guardwent red first and was a real finding: the module exports its rules, so its dispatch had to go behindisEntrypoint(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 carriesPACKAGES_DIR = 'packages', a bare root, and every gate that assembles a root must carry a recorded verdict. Recorded asREFUSE-UNSPELLABLE, joiningcheck:i18nandcheck:i18n-coverage, which reach their population through the samefindExtractConfigswalk. 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, andhintCoversmatched 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-debtis NOT MEASURED, declared rather than guessed: its--re-measurehalf 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.tsfiles and zeropackages/paths, so it cannot move a ledger number; CI builds the closure before that step.Repo tooling only — no published package source, root
package.jsonisprivate: true— soskip-changesetrather than a changeset.eslint --no-inline-configcovers 100% of this diff's lint-governed surface (0 findings); the non-JS paths come backFile ignored because no matching configuration was supplied, and the config enables no type-aware linting (eslint.config.mjs: "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file"), so no untouched file's verdict can move.Generated by Claude Code