Repository navigation
fix(ci): keep derived locales off the translation worklist - #222
Merged
Merged
Conversation
`zh-Hant` is generated from its `zh-Hans` sibling by `apps/docs/scripts/gen-zh-hant.mjs` and committed — never translated from English. `check-translations.mjs` derived its locale list from `apps/docs/lib/i18n.ts` and treated every non-default locale identically, so `--worklist` emitted 65 `en` to `zh-Hant` items. `docs/TRANSLATION.md` calls the worklist "the entire input to a pass", so those items instructed the pass to translate English into Traditional Chinese: a second, divergent Chinese voice on a page that already has one, which the generator overwrites and `gen-zh-hant.mjs --check` rejects in the meantime. `DERIVED_FROM` now lives in `.github/scripts/lib/derived-locales.mjs` and is imported by both `check-translations.mjs` and `check-translation-output.mjs`. The scripts' small readers stay duplicated — two independent parsers of one tree cross-check each other, and a mismatch between them is loud. Neither property holds for this map: it is a fact rather than a reader, nothing audits it, and a drift between two copies would surface only as a wasted pass. `stale`, `missing` and `guide-stale` are scoped to translated locales, and the worklist is built from those three buckets, so one exclusion point covers all four surfaces. `unstamped` and `orphan` deliberately still cover derived locales: those are defects in a file that exists, not work items. Worklist 505 to 440 entries with zh-Hant at 0 and no other locale moved; Stale 270 to 222, Missing 235 to 218. `--gate=release --require=zh-Hant` is now an error rather than a vacuous pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
hotlong
marked this pull request as ready for review
August 26, 2026 09:21
This was referenced Aug 26, 2026
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 free
to 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.
Fixes #216
zh-Hantis generated from itszh-Hanssibling byapps/docs/scripts/gen-zh-hant.mjsand committed — never translated from English.check-translations.mjsderived its locale list fromapps/docs/lib/i18n.tsand treated every non-default locale identically, so--worklistemitted 65entozh-Hantitems.docs/TRANSLATION.mdcalls the worklist "the entire input to a pass", so those items were instructions to translate English into Traditional Chinese: a second, divergent Chinese voice on a page that already has one.Structure: the shared leaf module, and why the duplication convention does not cover this
Adjudicated in the dispatch as the shared route; I checked it against the tree and the tree supports it, for a reason worth recording rather than just concurring with.
The re-declaration convention is documented once, over
SITE_URLincheck-locale-surface.mjs:142:That rationale carries two preconditions, and
DERIVED_FROMsatisfies neither:check-locale-surface.mjsaudits the app's sitemap;SITE_URLis a value the audited system produces, so an independent copy is a genuine oracle. Neither translation script audits the other — they are two consumers of one fact about the content pipeline. Two copies cross-check nothing.SITE_URLdrift lands every URL in two rule buckets at once.DERIVED_FROMdrift is silent by construction: one script skipping Traditional in its worklist while the other still compares it against English produces no finding anywhere. It surfaces as a wasted translation pass, days later, somewhere that does not point back at either file.So the shared module holds the fact; the small readers (
locales(),walk(),localeOf(), frontmatter parsing) stay duplicated, untouched..github/scripts/lib/derived-locales.mjscarries this reasoning inline so the next person does not have to re-derive it.Self-test hazard verified by measurement, not by reading.
tools/ci-scripts/run-self-tests.mjsscans withreaddirSyncwithoutrecursive, plus anisFile()filter. I wrote a throwaway.github/scripts/lib/__probe.mjscontaining the literal'--self-test', ran the runner, and it was not flagged while the top-level script was; the probe was then removed.lib/is outside the scan by construction.Missing/Stale: excluded, with the vacuous-pass hole closedThe card called these "arguably" wrong. I excluded them, after looking at both consumers:
$GITHUB_STEP_SUMMARY(translations.yml) — a human-read status surface. Thezh-Hantrow was structurally identical tozh-Hans(48/17 against 48/17), because a derived page carries its source's stamp. It is a duplicate row presented as a to-do list.--gate=release --require=LOCALEis the one place these numbers block.That second consumer is why exclusion alone was not enough. Removing the rows would make
--require=zh-Hantpass vacuously — a release gate that cannot go red, which is worse than no gate because someone is relying on it. So it now errors and names the locale carrying the real answer:The derived row is rendered as
—rather than dropped or zeroed: a zero reads as "nothing to do here", true by accident and a lie the moment the generator stops being run; an absent row hides that the locale ships at all. A footnote names the gate that does ask the question.unstampedandorphandeliberately still cover derived locales. They are defects in a file that exists — an unreachable Traditional page whose English source was retired, or one with no provenance — not work items, and both still block. Scoping them out alongside the work verdicts would have been the quiet half of this bug.Numbers
zh-HantStaletotalMissingtotalturbo run testPer-locale after:
{de:75, es:75, fr:75, ja:75, ko:75, zh-Hans:65}— verified programmatically that no other locale moved. The two column deltas are exactly the removedzh-Hantrows (48 stale + 17 missing = the 65 worklist items).The red fixture
check-translations.mjsgains a--self-test(13 cases). The rule under test is a negative — "no derived-locale item reaches the worklist" — which is the easy kind to pass by accident: a fixture with no derived pages, aDERIVED_FROMtypo disabling nothing, awalk()that never reached the tree all read as success.So every case is asserted against the same fixture tree surveyed twice: once with
DERIVED_FROM, once with an empty map — exactly what this script did before. The second run must emit what the first must not, and the self-test fails if it does not.Ablation confirming both exclusion points are load-bearing, each mutation confirmed on disk by anchor count before reading any result, restore proven by blob hash against the HEAD blob plus an empty
git diff HEAD:if (isDerived(l, derived)) continue;stale.zh-Hant.mdxemittedmissingloop back toLOCALESuncovered.zh-Hant.mdxemittedDeclared deviations from the stated file surface
Two, both reported rather than done silently:
docs/TRANSLATION.md— its interim warning ("--worklistdoes not know about this yet: it still emitsen → zh-Hantitems … Skip them") is made false by this change, so per the dispatch it had to go. Replaced with what is now enforced, plus a note that orphan/unstamped still block. The worklist definition at the top of the file was also incomplete and now names the exclusion.tools/ci-scripts/run-self-tests.mjs— mechanically forced, not chosen. The momentcheck-translations.mjsdeclares--self-test, the runner's own guard fails the build:declares --self-test but is not listed in SELF_TESTED. Listing it follows thecheck-locale-surface.mjsprecedent — self-test here, gate in the workflow — and its docblock's claim that the script "declares no self-test mode" is now false and was corrected. I also recorded that the scan is top-level only, so the next shared module does not have to rediscover it.Gates
All on the final commit
6d14f6a, working tree clean:pnpm turbo run type-checkTasks: 1 successful, 1 total(exit 0)pnpm turbo run buildTasks: 1 successful, 1 total(exit 0)pnpm turbo run test --force✓ 4 self-test(s) passed(exit 0)check-locale-surface.mjs✓ every advertised URL has a source file …(exit 0)check-translations.mjs✓ translations gate passed(exit 0)gen-zh-hant.mjs --check✓ zh-Hant: 73 generated file(s) match … byte for byte.(exit 0)check-translation-output.mjs --files— exit 0,✓ translation output gate passed (138 pre-existing finding(s) reported), the same 138mainreports. Measured against a comparison worktree atd454ccb: stdout and stderr are byte-identical (diffexit 0), which is the strongest available evidence that movingDERIVED_FROMto the shared module changed nothing there.Exit codes were captured before any pipe throughout, and each verdict above is the line the gate printed rather than a
$?reading.Filed, not fixed
.github/scripts/lib/, so a shared-module edit does not run the gates that consume it #221 —.github/workflows/translations.ymlfilters on.github/scripts/check-translation*.mjs, which does not match.github/scripts/lib/, so a future PR editing only the shared module skips the corpus gates. Bounded:ci.ymlhas no path filter andturbo.jsonalready declares$TURBO_ROOT$/.github/scripts/**fortest, so both self-tests still run. This PR edits bothcheck-translation*.mjsfiles, so the workflow runs here normally.Generated by Claude Code