From 751312f7fcd2dd56240d453af431fefb2e3640bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 09:45:42 +0000 Subject: [PATCH] ci(translations): enforce the output checklist as a validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-PR checklist in docs/TRANSLATION.md was prose: code fences byte-identical to English, URL set a subset, frontmatter keys matching, MDX components unchanged, no \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 ` /tmp/changed.txt +node .github/scripts/check-translation-output.mjs --files /tmp/changed.txt + +# The whole corpus, including debt you did not create. Never exits non-zero. +node .github/scripts/check-translation-output.mjs --report +``` + +| Rule | Enforced by | Verdict | +|:--|:--|:--| +| Only `content/docs/**/*..mdx` and `meta..json` changed | `check-translation-ownership.mjs` | blocking | +| Every changed file carries a current `source_sha` (`--stamp` writes it) | `check-translations.mjs` | blocking | +| Code fences byte-identical to the English source | `check-translation-output.mjs` `fence` | blocking | +| The page's URL set is a subset of the English page's | `check-translation-output.mjs` `url` | blocking | +| Frontmatter keys match the English file's keys exactly | `check-translation-output.mjs` `frontmatter` | blocking | +| MDX component names and props unchanged | `check-translation-output.mjs` `component` | blocking | +| No `