Skip to content

docs: publish translated docs in twelve languages and the tool that maintains them - #3280

Open
maxisbey wants to merge 5 commits into
mainfrom
docs/translations
Open

docs: publish translated docs in twelve languages and the tool that maintains them#3280
maxisbey wants to merge 5 commits into
mainfrom
docs/translations

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Ships the docs in twelve languages — de, es, fr, hi, ja, ko, pt (Brazilian), ru,
tr, uk, zh and zh-hant, each at /<code>/ — machine-translated from the English
pages, plus the small tool that keeps them current. English stays
the only source; everything under i18n/<lang>/pages/ is generated. Supersedes
#3231 and #3265 (same goal; this takes the review of both into account —
notably no anchor edits to the English docs, no nav-label subsystem, no
second-model gate, no state file).

  • scripts/docs/translations.py — three commands: status, translate --lang X [--pages …], stage --lang X. Pages are split at ## sections; only sections whose English changed are re-translated
    and everything else is carried forward byte-for-byte, so a one-line English
    edit is a one-line translated diff. Heading ids come from the site renderer
    itself and are pinned into the translation; code blocks are re-imposed from the
    English mechanically and link targets are checked against it. Provenance (section hashes) lives
    in each generated page's front matter — no side state.
  • Corrections are inputs: i18n/<lang>/instructions.md (register, voice,
    typography) and glossary.json (terms, keep-list, banned renderings), then
    translate --pages … for the affected pages; the generated pages are never
    hand-edited.
  • Build: language sites are staged (English overlaid with translations, a
    short notice after each H1 — machine-translated / behind the English page /
    shown in English), sidebar titles come from each translated page's own H1,
    and language builds are non-strict, so an English-only PR can't fail because
    of translations; a translation that no longer fits its English page is served
    in English until refreshed. The language sites add about a minute to the docs
    build in total; the API reference is linked, not rebuilt per language.
  • The language switcher shows code - name and keeps you on the current page
    when you change language. docs/translations.md explains all this to readers.

Intentional behavior changes

None to the SDK. The docs build now also produces the twelve language sites;
anthropic is added in a non-default translate dependency group (normal
installs don't get it).

Review notes

  • Registers/terminology are provisional pending native review — Portuguese has a
    reviewer lined up; reading order: i18n/general-prompt.md
    i18n/pt/instructions.md + glossary.json → a few pages under
    i18n/pt/pages/docs/translations.md → the tool.
  • Known follow-ups: CJK search segmentation needs a Zensical bump (blocked on
    packaging, tracked separately); French spacing uses ordinary rather than
    no-break spaces (the model won't emit U+00A0; a small deterministic pass could); the "API Reference" nav label stays English
    on language sites; a handful of terminology consistency notes for native
    reviewers are listed in i18n/<lang>/glossary.json notes as provisional.
  • Running the tool needs ANTHROPIC_API_KEY; nothing in CI calls the API.

AI Disclaimer

…tains them

Adds machine-translated documentation sites for Simplified Chinese,
Japanese, Korean and Brazilian Portuguese under /zh-CN/, /ja/, /ko/ and
/pt-BR/, generated from the English pages, which stay the only source.

scripts/docs/translations.py has three commands: status, translate and
stage. Pages are split at their sections and only sections whose English
changed are re-translated; the rest is carried forward byte-for-byte.
Heading ids come from the site renderer and are pinned into the
translation, code blocks are re-imposed from the English, and each
generated page records the section hashes it reflects in its own front
matter. Per-language inputs live in i18n/<lang>/instructions.md and
glossary.json; corrections go there, never into the generated pages.

The build stages each language (English overlaid with its translations
plus a short notice after each page title), takes sidebar titles from
the translated pages, links the single English API reference, and builds
language sites non-strictly so English-only changes never fail on
translations. A manually dispatched workflow refreshes a language and
opens a draft PR for its reviewers.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3280.mcp-python-docs.pages.dev
Deployment https://7c088c1a.mcp-python-docs.pages.dev
Commit 00e9565
Triggered by @maxisbey
Updated 2026-08-11 19:53:34 UTC

Drops the dispatch workflow and the issue form, the translate flags that
served them (--grep, --limit, --dry-run), the reviewers field, the extra
exit-code and schema-validation plumbing, and the link-resolution
machinery in stage: a translation that no longer fits the current English
page is simply served in English with the notice. What remains is
translate, stage and status plus the build wiring.
Adds Deutsch, español, français, हिन्दी, русский, Türkçe, українська and
繁體中文 alongside the existing four, using the same language codes and
switcher labels ("de - Deutsch") as other Python documentation sites;
zh-CN and pt-BR become zh and pt (Brazilian Portuguese). Each new language
gets its instructions and glossary plus a full set of generated pages.

The language switcher now keeps you on the current page when you change
language instead of returning to the home page.
@maxisbey maxisbey changed the title docs: publish translated docs (zh-CN, ja, ko, pt-BR) and the tool that maintains them docs: publish translated docs in twelve languages and the tool that maintains them Aug 11, 2026
Comment thread scripts/docs/translations.py Fixed
@maxisbey
maxisbey marked this pull request as ready for review August 11, 2026 13:59

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 657 files

Note: This PR contains a large number of files. cubic only reviews up to 200 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.

Re-trigger cubic

Comment thread i18n/languages.yml
Comment thread i18n/de/pages/advanced/middleware.md Outdated
Matches a heading's trailing attribute blocks with a pattern that reads
each block in one pass; the previous form could take a very long time on
a heading carrying many blocks followed by other text. Parsed results are
unchanged.

No-Verification-Needed: one regular expression rewritten with identical results, covered by a new unit test
Comment thread scripts/docs/translations.py Fixed
Comment thread i18n/README.md
Comment thread i18n/README.md Outdated
Comment thread i18n/README.md
Comment thread i18n/README.md Outdated
Comment thread i18n/README.md
Comment thread docs/js/language-switch.js
Comment thread docs/translations.md
Comment thread i18n/README.md Outdated
Comment thread CONTRIBUTING.md Outdated
Comment thread i18n/README.md Outdated
Comment thread docs/translations.md
Comment thread docs/translations.md
Comment thread docs/js/language-switch.js
Staging lays stored sections out by their recorded hashes before anything
is re-imposed, so a reordered English page can no longer pair code blocks
or heading ids with the wrong section; code fences are checked and restored
per section and a mismatch after carry-forward goes through the repair
turns like any other finding; list items and table rows are counted per
section so a shortened reply is sent back whatever language its filler is
in. The client is only built when a page will actually call the model,
stage clears its titles marker with the tree and stages every language in
one pass, and staged pages link to the English page and the API reference
relative to themselves. The language switcher keeps the fragment and query.
Older generated pages drop a front-matter key the tool no longer writes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 209 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CONTRIBUTING.md">

<violation number="1" location="CONTRIBUTING.md:131">
P3: The contributor instruction now points to a command form that cannot run, because `translate` requires a language code. Consider documenting the minimal valid invocation (`translate --lang <code> --pages ...`) so translation fixes are reproducible.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread CONTRIBUTING.md

## Documentation and Translations

Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --pages` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The contributor instruction now points to a command form that cannot run, because translate requires a language code. Consider documenting the minimal valid invocation (translate --lang <code> --pages ...) so translation fixes are reproducible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CONTRIBUTING.md, line 131:

<comment>The contributor instruction now points to a command form that cannot run, because `translate` requires a language code. Consider documenting the minimal valid invocation (`translate --lang <code> --pages ...`) so translation fixes are reproducible.</comment>

<file context>
@@ -128,7 +128,7 @@ pre-commit run --all-files
 ## Documentation and Translations
 
-Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the next translation run overwrites them. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is), and the fix carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.
+Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --pages` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.
 
 ## Pull Requests
</file context>
Suggested change
Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --pages` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.
Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/<lang>/`. Never edit the generated pages under `i18n/<lang>/pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --lang <code> --pages` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details.

Comment thread i18n/README.md
uv run --frozen python scripts/docs/translations.py status [--lang CODE]
uv run --frozen --group translate python scripts/docs/translations.py translate --lang CODE [--pages PATH ...]
uv run --frozen python scripts/docs/translations.py stage [--lang CODE]
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The 00e9565 fix for reorder cross-wiring covers only serve()'s changed==[] branch (scripts/docs/translations.py:944-948): when one English commit both edits a section and reorders others, the changed-non-empty branch still lays the stored translation out in its old positional order, so reimpose() pins today's heading ids (and copies fences) onto the wrong sections, and the staged site serves cross-wired anchors on all 12 language sites until someone runs translate. Fix: in the changed-non-empty branch, lay out unchanged sections by recorded hash (as the else branch and carry_forward already do), using positional text only for the edited indices.

Extended reasoning...

What the bug is. serve() in scripts/docs/translations.py has two layout strategies. When state.changed is empty (pure reorder/removal), commit 00e9565 made it hash-correct: stored = recorded_sections(state.translation); body = "".join(stored[value] for value in state.hashes). But when state.changed is non-empty — an ordinary docs-restructure commit that edits one section and moves others — the first branch runs body = state.translation.body, i.e. the stored translation in its old positional order, against today's reordered English. reimpose() then pins today's English heading ids onto the translation purely by position (_pin_headings) and copies English fences by position (_restore_fences), cross-wiring both.\n\nStep-by-step proof (reproduced by three independent verifiers executing the module's own functions at PR head). (1) English page with sections [Alpha, Beta, Gamma], faithfully translated; provenance records the three hashes in that order. (2) One English commit edits Alpha and swaps Beta/Gamma → English is now [Alpha', Gamma, Beta]. (3) classify(): h(Beta) and h(Gamma) are both still in the recorded set, so only the edited index lands in changedchanged=[0], status outdated. (4) serve() takes the if state.changed: branch, so body is [tAlpha, tBeta, tGamma] against English [Alpha', Gamma, Beta]. (5) reimpose() returns a clean str: the staged page contains ## Beta-DE {#gamma} and ## Gamma-DE {#beta} — every cross-page link to #gamma lands on the Beta translation's text. When the swapped sections each carry one fence, _restore_fences splices Gamma's code block into the Beta section (per-position counts 1==1), silently.\n\nWhy the existing safety net misses it. The Mismatch → serve-English fallback fires only on heading count/level differences, per-position fence-count differences, or per-position link-target differences. Two swapped prose-shaped sections with the same internal structure match on all three checks, so the reply sails through and stage exits 0 with only the generic "outdated" notice. The regression test added in 00e9565 (test_stage_lays_out_reordered_and_removed_sections_by_their_recorded_hashes_never_by_position) exercises only the pure-reorder case (changed==[]), so the commit's claim that "a reordered English page can no longer pair code blocks or heading ids with the wrong section" does not hold for the edit+reorder combination.\n\nImpact. deploy-docs.yml rebuilds on any docs/** merge, so from the moment the English restructure merges until someone manually runs translate, all 12 language sites serve pages whose anchors point at the wrong section's text (and, in the fence variant, whose code blocks sit under the wrong headings) — an unbounded window, with no diagnostic beyond the generic outdated notice. The window closes on the next translate run because carry_forward() is already hash-correct (it keys prior sections by recorded hash via recorded_sections(job.previous)), so only the staged/deployed output is wrong, never the committed translation files.\n\nHow to fix. In the changed-non-empty branch, lay out sections by recorded hash exactly as the else branch does, substituting the positional text only for the indices in state.changed (whose sections have no stored counterpart): e.g. build stored = recorded_sections(state.translation) plus a positional list of the stored body's sections, then for each index in state.hashes take stored[hash] when the hash is recorded and the positional stored section otherwise. Alternatively, fall back to English whenever the recorded hashes of the unchanged sections are not in the same relative order as in state.hashes. Extending the 00e9565 regression test with an edit+reorder case would lock the behavior in.\n\nWhy normal severity. This is not a latent corner case: editing a section while moving others is a routine docs-restructure commit shape, the failure ships incorrect published content (wrong anchor targets, potentially wrong code blocks) on every language site, and it is specifically the surviving half of a defect class the author already judged fix-worthy during this PR's review — 00e9565 fixed the sibling pure-reorder case and its test now gives false confidence that the whole class is closed. The fix is small and local to serve().

Comment thread i18n/README.md
uv run --frozen python scripts/docs/translations.py stage [--lang CODE]
```

`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`); `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unwrap() in scripts/docs/translations.py (lines 682-689) appends a missing trailing newline to a model reply but never trims a surplus one, and no later validator inspects blank-line structure, so a reply ending with an extra newline is written and recorded as current under the English section hashes. When a later English edit appends or reorders sections, carry_forward()/serve() splice the stored last section into a middle position and its surplus newline becomes a doubled blank line mid-page, carried byte-for-byte into every future run with no way to shed it short of --pages retranslation. One-line fix: when the English ends with a single trailing newline, normalize with reply = reply.rstrip("\n") + "\n" instead of only appending a missing one.

Extended reasoning...

What the bug is. unwrap() in scripts/docs/translations.py (lines 682-689) normalizes the tail of a model reply in one direction only: if english.endswith("\n") and not reply.endswith("\n"): reply += "\n". A reply that ends with a surplus newline (...\n\n where the English ends ...\n) is left untouched. No downstream check catches it either: reimpose() verifies fences, heading counts/ids and link targets, and validate() checks code spans, !!!/===/??? markers, list-item and table-row counts, banned glossary renderings and English placeholder patterns — a bare extra newline trips none of them. This was reproduced end-to-end by three independent verifiers executing the module's own functions on this head.\n\nWhy it persists forever. The accepted page is written with the surplus newline in its last section's bytes (sections() assigns trailing newlines to the last section), but with_provenance() records only the English section hashes (job.state.hashes) — never a digest of the translated bytes. classify() therefore reports the page current indefinitely; nothing ever flags the drifted section for re-translation, and carry_forward() reproduces it byte-for-byte on every subsequent run. update_request() even presents the drifted body to the model as the previous translation to reproduce line by line, so the drift self-propagates.\n\nHow it surfaces mid-page. The surplus newline is invisible while the section stays last. But when a later English edit appends a new section after it (or reorders sections), carry_forward()/serve() splice the stored section into a middle position via recorded_sections(). Because sections() assigns the blank line above a heading to the following section, the assembled body reads ...text.\n\n\n## Next where the English has ...text.\n\n## Next — a doubled blank line mid-page. The second reimpose/validate pass accepts this too (carried sections are exempt from validation anyway), the file re-records clean English hashes, and the drift is permanent.\n\nStep-by-step proof (as reproduced by the verifiers): (1) the model's reply for a two-section page ends Inhalt eins.\n\n against English Content one.\n; unwrap() leaves it, reimpose() and validate() return clean, the page is written and classified current. (2) The English page gains a new ## Two section after the last one; the page becomes outdated for that section only. (3) carry_forward() maps the stored first-section bytes (tail \n\n) ahead of the freshly translated new section (which starts \n## ), producing Inhalt eins.\n\n\n## Zwei — one blank line in the English, two in the translation. (4) All checks pass again; recorded_sections() stores the drifted bytes keyed by the clean English hash, so every future run carries them and status never reports anything wrong.\n\nImpact and why this is a nit. Rendered HTML is identical — Markdown collapses blank-line runs — so no reader ever sees the drift. The harm is that it permanently violates the tool's own contract (i18n/README.md line 18: sections are kept "byte for byte"; the PR description: "a one-line English edit is a one-line translated diff"; general-prompt.md: "the same blank lines between blocks"), producing unrelated whitespace hunks in generated-page diffs on every later partial update, with no detection or self-healing short of a full --pages retranslation of the affected page. The trigger also requires the model to emit a surplus trailing newline — plausible but not guaranteed — and this is docs-publishing tooling, not the shipped library.\n\nHow to fix. In unwrap(), when the English body ends with a single trailing newline, replace the append-only branch with reply = reply.rstrip("\n") + "\n" so both a missing and a surplus tail newline normalize to the English shape. (Alternatively, emit a findings entry for tail blank-line mismatches so the existing repair loop handles it, but the one-liner is simpler and mechanical.) A small unit test pairing an English page ending \n with replies ending `` (no newline), \n and `\n\n` would pin the behavior.

Comment on lines +26 to +31
function aim(event) {
const entry = event.target instanceof Element ? event.target.closest(entries) : null;
if (entry?.dataset.site && (event.type !== "keydown" || event.key === "Enter"))
entry.href = samePage(entry) + location.search + location.hash;
}
for (const type of ["click", "auxclick", "keydown"]) document.addEventListener(type, aim, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The click-time href enrichment in aim() appends location.search + location.hash unconditionally, but samePage() deliberately maps api/ pages to the bare language root (the API reference is English-only) — so switching language from an API deep link like /api/mcp/index/#mcp.ClientSession sends the reader to /de/#mcp.ClientSession, the language home page with a dangling fragment (and any ?h= highlight query carried along). Fix: skip the append when the current page is under api/, e.g. guard on !location.pathname.slice(site.length).startsWith("api/") or have samePage report whether the page carried over.

Extended reasoning...

What the bug is. docs/js/language-switch.js rewrites the theme's language-switcher entries to point at the current page on each language site. The path half already handles the one exception: samePage() (lines 12-15) computes entry.dataset.site + (page.startsWith("api/") ? "" : page), deliberately sending a reader on an API page to the bare language root, because the API reference exists only on the English site — the file's own header comment says so ("The API reference is English-only, so from there the entries keep pointing at the site roots"). But the click-time enrichment added in 00e9565, aim() (lines 26-31), sets entry.href = samePage(entry) + location.search + location.hash with no such guard, so the fragment and query are appended even when samePage() just resolved to the site root.\n\nThe code path that triggers it. aim is registered as a capture-phase listener for click, auxclick, and keydown (Enter). A reader on an English API deep link — e.g. /api/mcp/index/#mcp.ClientSession, which is the standard shape of shared mkdocstrings anchors — opens the language switcher and clicks a language entry. aim() fires before navigation, calls samePage(entry) (which returns /de/ because the page is under api/), then appends location.hash, producing /de/#mcp.ClientSession. Any ?h= search-highlight query is carried the same way.\n\nWhy existing code doesn't prevent it. The api/ special case lives entirely inside samePage()'s path computation; the fragment/query append happens outside it, in aim(). The subscribe-time href (document$.subscribe, line 20) appends nothing, so inspecting the rendered hrefs shows correct targets — the defect appears only on real clicks/auxclicks/Enter, which is exactly why it's easy to miss. The comment justifying the append ("Headings carry the same ids on every site") is true only for prose pages, which exist at the same path on every language site; it doesn't hold for the API-reference-to-root mapping.\n\nStep-by-step proof.\n1. Reader is on https://py.sdk.modelcontextprotocol.io/api/mcp/index/#mcp.ClientSession.\n2. On document$, each switcher entry gets entry.dataset.site = "/de/" (etc.) and entry.href = samePage(entry)/de/ — correct, per the API special case.\n3. Reader clicks the "de - Deutsch" entry. aim() runs in the capture phase: samePage(entry)/de/ (page api/mcp/index/ starts with api/, so "" is appended), then + location.search + location.hash/de/#mcp.ClientSession.\n4. The browser navigates to the German home page with a fragment for a heading id that only exists on the English API page. The browser ignores the unmatched fragment; the URL in the address bar carries the dangling #mcp.ClientSession (and any ?h= query lands on the home page too).\n\nImpact and fix. Impact is cosmetic — the reader still lands on the intended page (the language home), nothing 404s, the fragment simply dangles and a stray highlight query may ride along — hence nit severity. The fix is one line either way: guard the append in aim() (e.g. const onApi = location.pathname.slice(site.length).startsWith("api/") and skip location.search + location.hash when onApi), or have samePage() return both the target and whether the page path carried over, and append only when it did.

Comment thread i18n/README.md

- `languages.yml` — the registry: one entry per translated site (served at `/<code>/`), the model id, and the nav pages that stay in English.
- `general-prompt.md` — translation rules shared by every language. `notices.md` — English source of the three notes staged onto the pages of a translated site.
- `<code>/instructions.md` (register, voice, typography, terminology) and `<code>/glossary.json` (`keep`: terms that stay in English; `terms`: required renderings, each with an optional `note` and banned `avoid` renderings, which are checked) — human-authored, sent with every request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 load_glossary silently accepts a bare string for a term's avoid (or the top-level keep) — e.g. "avoid": "Gastgeber" instead of ["Gastgeber"] — because a str satisfies Sequence[str] by iterating its characters, so every letter becomes a banned rendering and every page of the next paid translate run fails validation with baffling per-letter findings. Add an isinstance shape check in load_glossary so this raises ConfigError (exit 2, before any API call), as its docstring already promises.

Extended reasoning...

What the bug is. load_glossary (scripts/docs/translations.py:307-317) promises in its docstring to raise ConfigError when glossary.json is "not that shape", but its exception net only catches OSError/JSONDecodeError/TypeError/KeyError from Term(**entry) and tuple(raw['keep']). Term is a plain frozen dataclass with avoid: Sequence[str] = () — dataclasses do not enforce annotations at runtime, and a Python str structurally is a Sequence[str]: iterating it yields its characters. So the natural singular slip "avoid": "Gastgeber" loads without error, and tuple("MCP") for a bare-string keep silently becomes ('M', 'C', 'P').\n\nThe code path that triggers it. Once loaded, glossary_prompt() renders the exploded string into every request's system prompt — - host → Host (never: G, a, s, t, g, e, b, e, r) — and validate()'s banned-rendering scan (translations.py:788-794, for avoid in term.avoid if avoid.casefold() in folded) checks each single letter as a banned substring of the translated output. A bare-string keep likewise lists single letters (- M, - C, - P) as "terms that stay in English" in every cached system prompt.\n\nStep-by-step proof (reproduced by verifiers executing the module's own functions on this head):\n\n1. Write glossary.json with {"keep": "MCP", "terms": [{"source": "host", "target": "Host", "avoid": "Gastgeber"}]}.\n2. load_glossary(path) returns successfully — no exception in the net fires, so the documented ConfigError never happens.\n3. glossary_prompt(g) renders - host → Host (never: G, a, s, t, g, e, b, e, r) into the system prompt for every page.\n4. validate('The server is great.', 'Der Server ist gut.', g, 'test') returns 7 findings of the form banned rendering 'e' of 'host' appears: use 'Host' — one per distinct character of "Gastgeber" present in the output.\n5. Since single letters like 'e' occur on essentially every page in every target language, the constraint is unsatisfiable: every open page of the next translate run burns its full 1 + MAX_REPAIRS paid model calls (no reply can avoid the letter 'e'), then fails with per-letter diagnostics that never point at the malformed glossary field.\n\nWhy existing code doesn't prevent it. The type annotation Sequence[str] is exactly the type that makes this slip invisible: it is satisfied at runtime by the wrong value, pyright doesn't check JSON-derived dicts, and no test exercises a bare-string shape. The failure surfaces two steps removed from its cause — in validate()'s output during a paid run — which is the opposite of the tool's documented fail-fast contract (offline exit 2 before any API call).\n\nWhy it's realistic and what the impact is. glossary.json is exactly the file this PR routes contributor and native-reviewer corrections into (CONTRIBUTING.md, docs/translations.md, and the i18n/README.md line this comment anchors on all direct edits there), and the singular "avoid": "X" is a natural mistake for a one-item ban list. The cost if it lands: one wasted paid run across all open pages, exit 1, and diagnostics that actively mislead. It's a nit rather than blocking because this is docs tooling, not the shipped mcp package — nothing in CI calls the API, the committed glossaries are well-formed, and recovery is just fixing the JSON and rerunning.\n\nHow to fix. In load_glossary, reject the string shape explicitly before constructing the dataclasses, e.g.:\n\npython\nif isinstance(raw["keep"], str) or any(isinstance(e.get("avoid"), str) for e in raw["terms"]):\n raise ConfigError(f"{path}: 'keep' and 'avoid' must be lists of strings, not a bare string")\n\n\nThis preserves the exit-2 fail-fast contract the docstring already documents.

Comment thread i18n/de/instructions.md
Comment on lines +169 to +170
good/bad example; never edit the generated `pages/` or `notices.md` next to
this file, which the next translation run overwrites.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The head commit fixed the inverted never-edit rationale in CONTRIBUTING.md, but the same false "the next translation run overwrites" claim survives verbatim in six hand-written style guides: i18n/de/instructions.md:169-170, i18n/fr/instructions.md:169-170, i18n/pt/instructions.md:189-190, i18n/ru/instructions.md:169-170, i18n/zh/instructions.md:157-158, and i18n/zh-hant/instructions.md:169-170. The tool never overwrites a hand-edited page — classify() only hashes the English source, so the edit silently persists and is carried forward — so these six sentences should be reworded to match the corrected CONTRIBUTING.md wording (or the neutral phrasing the other six languages use).

Extended reasoning...

What the bug is. Six of the twelve hand-written per-language style guides end with a sentence telling contributors never to edit the generated pages/ (and, in most, notices.md) "which the next translation run overwrites." That rationale is inverted for the shipped tool: a hand edit is sticky, not transient. The de, fr, pt, ru, zh, and zh-hant files all carry the sentence verbatim (de/fr/ru/zh-hant at lines 169-170, pt at 189-190, zh at 157-158), while the other six languages (es, hi, ja, ko, tr, uk) state the same never-edit policy without the false mechanism claim.

Why the claim is factually wrong. classify() in scripts/docs/translations.py (~line 500) computes a page's status purely by comparing the recorded English section hashes in the page's front matter against the current English source — tuple(hashes) == recorded. The translated body is never hashed or inspected. So a hand-edited generated page whose English source is unchanged stays current, is never selected by select_jobs() (which, absent --pages, picks only non-current pages), and no translation run ever touches it. Worse, when the English does later change in some other section, carry_forward() (~line 843) rebuilds every unchanged section byte-for-byte from recorded_sections(job.previous) — i.e. from the on-disk hand-edited body — and _validate_open() exempts carried sections from validation. The hand edit is thereby laundered into fresh tool output, permanently escaping the glossary and parity checks.

Why this survived the head commit. An earlier review comment flagged exactly this inverted rationale on CONTRIBUTING.md line 131, and the head commit (00e9565) accepted it, rewriting that sentence to the accurate mechanism: "the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix." But the commit touched no instructions.md files, so the same false claim survives in six of the seven places it appeared. This is not a duplicate of that earlier finding — that comment named only CONTRIBUTING.md and was resolved there; these six occurrences are the residual sites the fix missed.

Why it matters. These files are the very documents the PR's recommended reading order sends contributors to (i18n/general-prompt.mdi18n/<lang>/instructions.md → ...), and the "it gets overwritten anyway" justification actively invites the failure mode it is meant to prevent: a contributor who trusts it may hand-edit a generated page believing the edit is harmless and temporary, when in fact it persists indefinitely, undetected, and gets reproduced as future tool output. The corrected CONTRIBUTING.md wording exists precisely to close that trap, so leaving the old wording in the per-language guides undercuts the fix the author already made.

Step-by-step proof. (1) A contributor reads i18n/de/instructions.md:169-170, sees "never edit the generated pages/ ... which the next translation run overwrites," and hand-edits a sentence in i18n/de/pages/servers/tools.md, assuming the tool will replace it anyway. (2) On the next translate run, classify() re-hashes the English docs/servers/tools.md, finds tuple(hashes) == recorded, and reports the page current — it is never re-run; the "overwrite" never happens. (3) Months later a different ## section of the English page changes; the page becomes outdated and translate runs, but carry_forward() copies the hand-edited section byte-for-byte into the regenerated file, skipping validation for it. (4) The edit is now indistinguishable from tool output, and no command (status records only English hashes) can ever surface it.

How to fix. A mechanical rewording of six sentences: replace the "which the next translation run overwrites" clause in each of the six files with either the corrected CONTRIBUTING.md rationale (the tool can't tell a hand edit from its own output, so the edit persists unchecked and is carried forward) or the neutral phrasing the other six language guides already use (the generated pages are never edited by hand; corrections go into this file or the glossary). Docs-accuracy only — nothing breaks at merge time — hence nit severity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants