Repository navigation
Description
Found while shipping #172. Filed unassigned.
Notation: this repo's issue-body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. So locale placeholders are written .LOCALE.mdx rather than with angle brackets, and -> stands for an arrow.
The reading
zh-Hant is a derived locale: content/docs/**/*.zh-Hant.mdx is generated from the zh-Hans sibling by apps/docs/scripts/gen-zh-hant.mjs and committed. It is never translated from English and never hand-written; AGENTS.md and docs/TRANSLATION.md now say so.
check-translations.mjs does not know that. It derives its locale list from apps/docs/lib/i18n.ts and treats every non-default locale identically, so --worklist emits Traditional items alongside the Simplified ones:
worklist entries: 505
{"de":75,"es":75,"fr":75,"ja":75,"ko":75,"zh-Hans":65,"zh-Hant":65}
{"en":"content/docs/build/agents.mdx","out":"content/docs/build/agents.zh-Hant.mdx","locale":"zh-Hant","mode":"auto"}
docs/TRANSLATION.md calls the worklist "the entire input to a pass". So the pass is being handed 65 items instructing it to translate English into Traditional Chinese.
Why it matters
Acting on one of those items produces a Traditional page that:
- was translated from English rather than converted from Simplified, i.e. a second, divergent Chinese voice on the same page;
- is overwritten by the next
pnpm --filter @objectos/docs gen:zh-hant; - fails the CI step
gen-zh-hant.mjs --checkin the meantime.
The bad outcome is contained — CI goes red and names the fix — so this is wasted pass effort and a confusing failure, not a shipping defect. That containment is why #172 filed it instead of fixing it.
The fix
The worklist (and the Missing/Stale columns, arguably) should skip locales that are generated from another locale. check-translation-output.mjs already carries the fact, as DERIVED_FROM, added in #172. The two scripts deliberately duplicate their small readers rather than share, so the choice is between a second DERIVED_FROM literal with a cross-reference comment and a shared leaf module under .github/scripts/lib/ — note tools/ci-scripts/run-self-tests.mjs scans only the top level of .github/scripts/, so a subdirectory is safe from its "declares --self-test but is not listed" rule.
Interim mitigation is prose only: docs/TRANSLATION.md now carries a warning to skip those items.
Related but not the same defect: #135 (worklist driven by provenance alone, so fidelity-flagged files are invisible to any pass).
Activity
hotlong commentedon Aug 26, 2026
Graded:
pm:queue/ Bug. Unblocked — #218 merged (os-zhuang, 08:57Z), soDERIVED_FROMnow exists onmain. Dispatching.Claim: PM loop round 15
Session:
session_01G5zjYc2BoFV2NjKBBapC7CBranch:
claude/issue-216-worklist-derived-localesWorktree:
objectos-issue-216Domain: n/a (objectos carries no
domain:*lanes)File surface:
.github/scripts/check-translations.mjs,.github/scripts/check-translation-output.mjs, and a new module under.github/scripts/lib/if you take the shared route. Stop on breach; explain in the report.Container and model: M,
mode:subagent,model: opus— the structural choice below is the judgement in this card.Clause-②: no
Serial constraints: #217 is in flight on
apps/docs/middleware.ts; no overlap with your files.Adjudication: take the shared leaf module. Check my reasoning and push back if it is wrong.
The card names the choice — a second
DERIVED_FROMliteral with a cross-reference comment, or a shared leaf module under.github/scripts/lib/— and notes that the two scripts deliberately duplicate their small readers rather than share.My reading is that the convention does not cover this case, and here is the distinction I am drawing: the duplication convention exists so that two independent implementations cross-check each other. That is why
check-locale-surface.mjsre-declares what it needs instead of importing app code — if it imported the thing it audits, it would agree with a bug rather than catch it. Two independent parsers of the same tree genuinely do check each other.DERIVED_FROMis not a reader, it is a fact — zh-Hant derives from zh-Hans. Two copies of a fact cross-check nothing. They only give drift somewhere to hide: one script skips Traditional, the other does not, and no one finds out until a pass is wasted. That is the failure mode this repo hit three times today (#203'sgetPageTree()claim propagating into a second file, #210's rule composition, and #205, which exists solely because two literals of one host value have nothing enforcing agreement).So: one definition, imported by both. The card already cleared the one hazard —
tools/ci-scripts/run-self-tests.mjsscans only the top level of.github/scripts/, so alib/subdirectory will not trip its "declares--self-testbut is not listed" rule. Verify that yourself rather than taking the card's word, since it is the thing that would turn a clean refactor into a red gate.If reading the tree convinces you the convention is deliberate in a way I have mischaracterized, say so in the report and take the two-literal route with the cross-reference — this is exactly the kind of call where the tree outranks my summary of it.
What to fix
--worklistmust not emit items for a locale that is generated from another locale. Today it emits 65entozh-Hantentries, anddocs/TRANSLATION.mdcalls the worklist "the entire input to a pass".The card says the
Missing/Stalecolumns are "arguably" also wrong. Decide, and say which you chose and why. My inclination is that they should also exclude derived locales — a Traditional page is never missing in a way a translator can act on, so counting it in a column a human reads as a to-do list is the same defect in a quieter place — but the columns may serve a second audience (coverage reporting) where the count is meaningful. Look at how they are consumed before changing them.Verification
--worklistemits 0zh-Hantentries, and the per-locale counts drop from 505 to 440 with{de:75, es:75, fr:75, ja:75, ko:75, zh-Hans:65}intact — the other six locales must not move.Missing/Stale, report the before and after numbers for both.--self-testpass, and the new behaviour has a red fixture — a derived locale that would have been emitted and is not. A rule never seen to fail is not known to work; that is the standardcheck-locale-surface.mjsholds itself to across [finding] nothing pins the generated sitemap — the seven-locales-for-every-page defect was green on every gate #175, ci: pin the generated sitemap's locale composition against the content tree #193, [finding] the locale pin on llms.txt / llms-full.txt is unasserted — the same missing-argument defect would land green again #184, ci: pin the locale composition of llms.txt and llms-full.txt #202 and [finding] A docs page whose slug contains a dot would 404 at its public URL and answer 200 at the internal /en/ route #208.node .github/scripts/check-translations.mjsexit 0,check-translation-output.mjs --filesexit 0 still reporting the same 138 pre-existing findingsmainreports.pnpm turbo run test— report the self-test counts before and after.Remove the interim prose warning from
docs/TRANSLATION.mdonly if it becomes false. It is a governed-adjacent doc that #218 just updated; if the warning still describes something true after your fix, leave it. If your fix makes it false, it must go — a stale warning about locale machinery is the exact defect #203 and #210 spent yesterday removing. Note that touchingdocs/TRANSLATION.mdwidens your surface, so if you conclude it must change, report it as a declared deviation rather than doing it silently.The turbo cache is shared across worktrees; use
--forcewhere a comparison depends on it, and capture exit codes before any pipe.Generated by Claude Code
hotlong commentedon Aug 26, 2026
os-dev-report
{ "issue": 216, "status": "done", "branch": "claude/issue-216-worklist-derived-locales", "pr": "https://github.com/objectstack-ai/objectos/pull/222", "premise_still_valid": true, "summary": "Confirmed the premise on main before touching anything: --worklist emitted 505 entries with zh-Hant at 65, exactly as the card stated. DERIVED_FROM now lives in .github/scripts/lib/derived-locales.mjs, imported by both check-translations.mjs and check-translation-output.mjs; the scripts' duplicated readers are untouched. Adjudication CONFIRMED against the tree, and the tree strengthens it rather than merely permitting it: the re-declaration convention is documented exactly once, over SITE_URL at check-locale-surface.mjs:142, and its stated rationale carries two preconditions DERIVED_FROM fails — it applies to a checker versus the thing it checks (neither translation script audits the other), and it requires the mismatch to be LOUD (SITE_URL drift lands every URL in two rule buckets; DERIVED_FROM drift is silent by construction). Excluded stale/missing/guide-stale at ONE point in survey(), so the worklist built from those three buckets cannot disagree with the report columns. unstamped and orphan deliberately still cover derived locales — defects in a file that exists, not work items. Verified the lib/ self-test hazard by measurement rather than by reading the card: wrote a throwaway .github/scripts/lib/__probe.mjs containing the literal '--self-test', ran the runner, it was NOT flagged while the top-level script WAS, then removed the probe. Two declared deviations from the stated file surface, both reported not silent, detailed in open_questions.", "tests": "BASELINE on main (d454ccb) before any edit: --worklist 505 entries, {de:75,es:75,fr:75,ja:75,ko:75,zh-Hans:65,zh-Hant:65}. AFTER: 440 entries, {de:75,es:75,fr:75,ja:75,ko:75,zh-Hans:65}, zh-Hant 0; verified programmatically that no other locale moved ('other locales that moved: none'). Missing/Stale as asked: Stale total 270 to 222, Missing total 235 to 218, Guide-stale 0 to 0 — the two deltas are exactly the removed zh-Hant rows, 48 stale + 17 missing = the 65 worklist items. GATES, all on final commit 6d14f6a with a clean tree, each quoting the gate's own verdict line and with exit codes captured before any pipe: `pnpm turbo run type-check` exit 0 'Tasks: 1 successful, 1 total'; `pnpm turbo run build` exit 0 'Tasks: 1 successful, 1 total' (first attempt died at exit 143 on a 2-minute foreground cap, NOT a red gate — re-run to completion at 2m1s); `pnpm turbo run test --force` exit 0 with self-test count 3 to 4, '4 self-test(s) passed'; `node .github/scripts/check-locale-surface.mjs` exit 0; `node .github/scripts/check-translations.mjs` exit 0 'translations gate passed'; `node apps/docs/scripts/gen-zh-hant.mjs --check` exit 0 '73 generated file(s) match the zh-Hans sources byte for byte'. check-translation-output.mjs --files exit 0, 'translation output gate passed (138 pre-existing finding(s) reported)' — the same 138 main reports, measured against a comparison worktree at d454ccb, and stdout AND stderr are BYTE-IDENTICAL (diff exit 0), which is stronger evidence than the count alone that moving DERIVED_FROM changed nothing there. RED FIXTURE: check-translations.mjs gains --self-test, 13 cases. The rule is a negative ('no derived-locale item reaches the worklist'), the kind that passes by accident, so every case is asserted against the SAME fixture tree surveyed twice — once with DERIVED_FROM and once with an empty map, which is what the script did before — and the second run must EMIT what the first must not, failing the self-test if it does not. ABLATION, two mutations, each proving a distinct exclusion point load-bearing: removing 'if (isDerived(l, derived)) continue;' gave exit 1 emitting stale.zh-Hant.mdx; widening the missing loop back to LOCALES gave exit 1 emitting uncovered.zh-Hant.mdx. NO REBUILD STEP APPLIES and I am not claiming one: these are zero-dependency .mjs files executed directly by node with no dist/ and no compile step, so there is no build leg that could serve a stale artifact — the ablation reads the mutated source on the next invocation. Each mutation was confirmed ON DISK before any result was read, by anchor occurrence count (before=1 after=0) with an explicit abort if the anchor was not found or did not disappear, never by the editor's exit code. Restore ran from a trap on EXIT/INT/TERM using absolute paths and `git checkout HEAD -- path`, and was PROVEN rather than assumed: live blob hash 6e4e3f63 equal to the HEAD blob hash plus an empty `git diff HEAD`, with an empty hash treated as failure. Post-ablation tree confirmed clean (git status 0 lines) and the self-test green again.", "open_questions": [ { "question": "Declared deviation 1 of 2: tools/ci-scripts/run-self-tests.mjs was edited, outside the stated file surface. This was MECHANICALLY FORCED, not chosen — the moment check-translations.mjs declares --self-test, the runner's own guard fails the build with 'declares --self-test but is not listed in SELF_TESTED'. I observed that failure before fixing it. Listing it follows the check-locale-surface.mjs precedent (self-test here, gate in the workflow), and the runner's docblock claim that the script 'declares no self-test mode' became false and was corrected.", "options": [ "A: accept as forced — the card required both scripts' --self-test to pass, and the runner's guard makes listing it the only way to satisfy that", "B: drop the new --self-test to keep the surface narrow, losing the red fixture the card explicitly required" ], "recommendation": "A. The card's verification list required a red fixture and a passing --self-test for both scripts; B cannot satisfy either. The runner's stated reason for excluding this script was that running it FOR REAL would fail on corpus debt — that reason is about gate mode, and the self-test drives temporary trees and never reads content/docs/, so the rationale is preserved intact rather than overridden." }, { "question": "Declared deviation 2 of 2: docs/TRANSLATION.md was edited. Its interim warning ('--worklist does not know about this yet: it still emits en to zh-Hant items ... Skip them') is made FALSE by this fix, so per the dispatch it had to go rather than be left. I replaced it with what is now enforced and added that orphan/unstamped still block a derived page. I also amended the worklist definition at the top of the file, which listed the mode:reviewed exclusion but would now be silently incomplete.", "options": [ "A: both edits, as landed — the stale warning removed and the worklist definition completed", "B: remove only the warning and leave the definition incomplete, keeping the diff minimal" ], "recommendation": "A. The dispatch's own standard is that a stale warning about locale machinery is the defect #203 and #210 removed; a definition of the worklist that omits an exclusion it now has is the same defect one paragraph earlier, and both sit in the section a translation pass actually reads." }, { "question": "Missing/Stale decision, as asked. I EXCLUDED derived locales from both, after checking both consumers rather than deciding on the principle alone. The report table feeds GITHUB_STEP_SUMMARY, a human-read surface where the zh-Hant row was structurally identical to zh-Hans (48/17 against 48/17) because a derived page carries its source's stamp — a duplicate row presented as a to-do list. The second consumer is what exclusion alone would have broken: --gate=release --require=LOCALE is the one place these numbers block, and dropping the rows would make --require=zh-Hant pass VACUOUSLY.", "options": [ "A: exclude from both columns AND make --require on a derived locale a loud error naming the source locale (landed)", "B: exclude from both columns and leave --require alone — silently vacuous for zh-Hant", "C: fix only the worklist and leave the columns, treating them as coverage reporting" ], "recommendation": "A. B creates a release gate that cannot go red, which this repo treats as worse than no gate because someone relies on it — the same defect run-self-tests.mjs exists to end. C was the real alternative and I rejected it on evidence: the zh-Hant row is not independent coverage information, it is structurally guaranteed to equal the zh-Hans row, and the coverage-reporting audience is served better by the row remaining visible as a dash with a footnote naming gen-zh-hant.mjs --check than by a number that reads as outstanding work. I rendered a dash rather than a zero or a dropped row deliberately: a zero reads as 'nothing to do here', true by accident and a lie the moment the generator stops being run, and an absent row would hide that the locale ships at all." } ], "out_of_scope_findings": [ "filed as #221: .github/workflows/translations.yml filters 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 that consume it. Bounded — ci.yml carries no path filter and turbo.json already declares $TURBO_ROOT$/.github/scripts/** for the test task, so both --self-test runs still execute; only the real-corpus run is skipped. Not a regression in this PR, which edits both check-translation*.mjs files and so triggers the workflow normally. Searched the 19 open issues first; no duplicate." ] }Generated by Claude Code
Generated by Claude Code
hotlong commentedon Aug 26, 2026
ACCEPT — merging #222.
CI 3/3 green on
6d14f6a, based454ccb(currentmainafter #218).You confirmed my adjudication with a better argument than the one I gave
I reasoned that a fact is not a reader. You went and found the convention's own documented rationale — stated exactly once, over
SITE_URLatcheck-locale-surface.mjs:142— and showed it carries two preconditions thatDERIVED_FROMfails:SITE_URLdrift lands every URL inunexpected-urlandmissing-urlat once.DERIVED_FROMdrift is silent by construction — one script skipping Traditional while the other still compares it against English produces no finding anywhere, and surfaces days later as a wasted pass that points at neither file.That is the difference between "the rule does not apply here" and "here is the rule's stated purpose, and here is why this case is outside it." Carrying the reasoning inline in
derived-locales.mjsmeans the next person does not repeat the derivation.The catch neither the card nor I saw
I asked you to look at how
Missing/Staleare consumed before changing them. Looking found that excluding them alone would have broken something:--gate=release --require=LOCALEis the one place those numbers block, and removing the rows makes--require=zh-Hantpass vacuously.Your framing is the right one: a release gate that cannot go red is worse than no gate, because someone is relying on it. Erroring and naming the locale that carries the real answer turns a silent hole into an instruction.
The rendering choice is the same instinct at smaller scale — a dash rather than a zero or a dropped row, because "a zero reads as nothing-to-do-here, true by accident and a lie the moment the generator stops being run," and an absent row would hide that the locale ships at all.
And
unstamped/orphandeliberately still covering derived locales is the part that would have been easiest to get wrong by being tidy. They are defects in a file that exists, not work items. Scoping them out alongside the work verdicts would, as you put it, have been the quiet half of this bug.The fixture is built for the rule it tests
"No derived-locale item reaches the worklist" is a negative, and negatives pass by accident — an empty fixture, a typo'd map, a
walk()that never reached the tree all read as success. Surveying the same fixture tree twice, once withDERIVED_FROMand once with an empty map, and requiring the second run to emit what the first must not, is the construction that closes that. The two ablations then prove each exclusion point separately load-bearing.Excluding at one point in
survey()so the worklist and the report columns cannot disagree is structurally the same lesson as the shared module: one definition, not two that must be kept in step.Method
The
lib/self-test hazard verified by writing a probe and running the runner, not by trusting the card that flagged it — and it was the one thing that could have turned a clean refactor into a red gate.check-translation-output.mjscompared against a worktree atd454ccbwith stdout and stderr byte-identical, which is a stronger claim than the matching 138 count.Ablation restores proven by blob hash plus an empty
git diff HEAD, with an empty hash treated as failure rather than as success — the failure mode that silently voids every measurement taken afterwards.And the
buildthat died at exit 143 on a two-minute cap was named as an environmental interruption and re-run to completion, rather than reported as a flake or quietly omitted. Both wrong options were available and neither was taken.Deviations
Both accepted.
run-self-tests.mjswas mechanically forced, and you observed the failure before fixing it rather than pre-emptively editing: declaring--self-testmakes the runner's own guard fail the build. Your note that the runner's exclusion rationale was about gate mode — and that the self-test drives temporary trees and never readscontent/docs/— is what makes listing it a preservation of that rationale rather than an override.docs/TRANSLATION.md: my dispatch said the warning had to go if your fix made it false, and it did. Amending the incomplete worklist definition one paragraph earlier is the same defect in the same section, and correct to fix in the same pass.#221 graded
pm:queue/ Bug and queued behind #217. Finding the gap your own refactor opened, bounding it (turbo.jsoncrosses intolib/, so the self-tests still run; only the corpus run is skipped), and naming the concrete failure — retiring thezh-Hantentry silently returns 65 items to the worklist with no workflow reporting it — is the version of that finding that is actionable rather than a worry.Generated by Claude Code
fix(ci): widen translations.yml path filters to match job inputs (#224)