diff --git a/.github/scripts/check-translation-output.mjs b/.github/scripts/check-translation-output.mjs
new file mode 100755
index 0000000..cdbe6b3
--- /dev/null
+++ b/.github/scripts/check-translation-output.mjs
@@ -0,0 +1,704 @@
+#!/usr/bin/env node
+/**
+ * Translation output validator — the `docs/TRANSLATION.md` pre-PR checklist as
+ * a machine rule.
+ *
+ * `check-translations.mjs` answers "was this derived from the current English?"
+ * (provenance). This one answers the question a stamp cannot: "is what came
+ * back actually a translation of that page?" A file can carry a perfectly
+ * current `source_sha` and still have had its code samples repaired, its links
+ * rewritten, or half its body dropped.
+ *
+ * The translation pass is a model with an editor, and its failure mode is not
+ * malice but helpfulness — fixing a link it believes is broken, repairing a
+ * code sample, restructuring a table while translating. That produces a diff
+ * that reads like a translation and is not one. A prose checklist is the
+ * weakest possible form of every rule below: it is checked by the same agent
+ * that just decided the link was broken.
+ *
+ * ## Two weight classes
+ *
+ * `fence`, `url`, `frontmatter`, `component` and `length` are FIDELITY rules.
+ * They catch a translation that stopped being one.
+ *
+ * `unsafe` is not in that class. The English MDX is authored by anyone who can
+ * open a pull request, it is fed to a model, and the model's output is
+ * committed to a public site. MDX compiles to JSX: a `\n` },
+ { name: 'javascript: URL in the translation', expect: ['unsafe'], xx: (s) => `${s}\nDer Wert javascript:alert(1) ist verboten.\n` },
+ { name: 'on*= handler in the translation', expect: ['unsafe'], xx: (s) => `${s}\n
x
\n` },
+ {
+ name: 'script tag in the ENGLISH source',
+ expect: ['unsafe'],
+ en: (s) => `${s}\n\n`,
+ },
+ { name: 'body truncated to half', expect: ['length'], scale: 0.5 },
+ { name: 'body padded with invented content', expect: ['length'], scale: 1.5 },
+];
+
+function selfTest() {
+ const locale = LOCALES.find((l) => LOCALE_EXPANSION[l] !== undefined);
+ if (!locale) throw new Error('no calibrated locale in i18n.ts — cannot self-test the length rule');
+ const dir = mkdtempSync(join(tmpdir(), 'translation-output-'));
+ const en = join(dir, 'page.mdx');
+ const xx = join(dir, `page.${locale}.mdx`);
+ let failed = 0;
+ try {
+ for (const c of CASES) {
+ writeFileSync(en, (c.en ?? ((s) => s))(fixtureEn()));
+ writeFileSync(xx, (c.xx ?? ((s) => s))(fixtureXx(locale, c.scale ?? 1)));
+ const fired = [
+ ...new Set([
+ ...checkUnsafe(en, readFileSync(en, 'utf8')).map((f) => f.rule),
+ ...checkUnsafe(xx, readFileSync(xx, 'utf8')).map((f) => f.rule),
+ ...checkFidelity(xx, en, locale).map((f) => f.rule),
+ ]),
+ ].sort();
+ const want = [...c.expect].sort();
+ const ok = fired.join(',') === want.join(',');
+ if (!ok) failed += 1;
+ console.log(
+ `${ok ? '✓' : '✗'} ${c.name.padEnd(46)} fired [${fired.join(' ') || '—'}]` +
+ (ok ? '' : ` expected [${want.join(' ') || '—'}]`),
+ );
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('');
+ const covered = new Set(CASES.flatMap((c) => c.expect));
+ for (const rule of RULES) {
+ if (!covered.has(rule)) {
+ console.error(`✗ rule "${rule}" has no fixture that trips it`);
+ failed += 1;
+ }
+ }
+ if (failed) {
+ console.error(`\n✗ self-test: ${failed} case(s) did not behave as declared`);
+ process.exit(1);
+ }
+ console.log(`✓ self-test: ${CASES.length} case(s) on locale "${locale}", every rule demonstrated able to fail`);
+}
+
+main();
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index 8d974d6..31f29fd 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -51,5 +51,41 @@ jobs:
# Blocking: unstamped and orphaned translations. Non-blocking: stale and
# missing — English lands first by design and translations catch up in a
# separate pass (docs/TRANSLATION.md).
+ #
+ # `shell: bash` is load-bearing here, not tidiness. The DEFAULT shell for
+ # a `run:` step is `bash -e {0}` with no pipefail, so in `node … | tee`
+ # the step takes tee's exit status and a gate that exits 1 passes the job
+ # silently — measured, not assumed: this gate exits 1 on a corpus with
+ # findings and the piped step still reports 0. Naming the shell gets
+ # `bash --noprofile --norc -eo pipefail {0}`, which propagates it.
- name: Freshness
+ shell: bash
run: node .github/scripts/check-translations.mjs | tee -a "$GITHUB_STEP_SUMMARY"
+
+ # 裁决: a validator observed only green is indistinguishable from one that
+ # cannot go red. The fixtures run before the corpus does, so a rule that
+ # stopped being able to fail fails the job on its own.
+ - name: Output validator self-test
+ shell: bash
+ run: node .github/scripts/check-translation-output.mjs --self-test
+
+ # Fidelity and safety of the translations themselves — the checklist in
+ # docs/TRANSLATION.md § Before opening the PR. The whole corpus is scanned
+ # either way; what is scoped is what BLOCKS. On a pull request that is the
+ # locale files the PR changed, plus any `unsafe` finding anywhere in the
+ # corpus. On push to main there is no PR diff to scope to, so it reports:
+ # the corpus carries fidelity debt older than this gate, and a job that is
+ # red on main for debt nobody is touching is a job someone deletes.
+ - name: Output
+ shell: bash
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then
+ git diff --name-only "origin/${BASE_REF}...HEAD" > changed-output.txt
+ node .github/scripts/check-translation-output.mjs --files changed-output.txt \
+ | tee -a "$GITHUB_STEP_SUMMARY"
+ else
+ node .github/scripts/check-translation-output.mjs --report \
+ | tee -a "$GITHUB_STEP_SUMMARY"
+ fi
diff --git a/docs/TRANSLATION.md b/docs/TRANSLATION.md
index 78c10b4..38f2f4b 100644
--- a/docs/TRANSLATION.md
+++ b/docs/TRANSLATION.md
@@ -109,24 +109,67 @@ source, which is the thing this whole system is built to avoid.
## Before opening the PR
-A translation PR must satisfy all of these. They are mechanical; check them
-rather than trusting the output:
-
-- [ ] Only `content/docs/**/*..mdx` and `meta..json` changed.
-- [ ] Every changed file carries a `translation:` block with a current
- `source_sha` (`--stamp` writes it).
-- [ ] Code fences are byte-identical to the English source.
-- [ ] The set of URLs in each page is a subset of the English page's URLs.
-- [ ] Frontmatter keys match the English file's keys exactly.
-- [ ] MDX component names and props are unchanged.
-- [ ] No `