diff --git a/.gitattributes b/.gitattributes index af3ad1281..75a2fe64d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,8 @@ +# Normalise line endings in the repository, matching .editorconfig's +# `end_of_line = lf`. Without this a checkout on Windows can reintroduce +# CRLF and the next save shows up as a whole-file diff. +* text=auto eol=lf + /.yarn/** linguist-vendored /.yarn/releases/* binary /.yarn/plugins/**/* binary diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48536d9b4..8ae2f6d45 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,13 +55,25 @@ jobs: - name: Install dependencies (immutable) run: yarn --immutable + # The integrity check is what stands between a truncated machine + # translation and the published site, so it is itself tested -- on every + # event, not just the pushes that translate. + - name: Test the translation integrity check + run: yarn test:translation + - name: generate id if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: ./add_id.sh docs/en/solutions && ./add_id.sh docs/en/articles - - name: translate + # Translate, verify, and retranslate whatever came back incomplete. A single + # pass is not trustworthy: this model has dropped a whole section, invented + # a table, and rewritten link targets into the literal `URL` from its own + # prompt example -- none of which the build would notice. If a document is + # still incomplete after the retries, this step fails and nothing is + # committed, so a short page never reaches the site. + - name: Translate and verify if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: yarn translate -s en -t zh -g "*" + run: node scripts/translate-verified.mjs - name: Build run: yarn build @@ -81,3 +93,17 @@ jobs: with: branch: ${{ github.ref }} github_token: ${{ secrets.KNOWLEDGE_SECRET }} + + # Nothing is committed unless the build passes, so a failed run otherwise + # discards the generated translations and leaves no way to see what the + # model produced. Keep them for post-mortems. docs/en is included because + # add_id.sh rewrites it earlier in the same run. + - name: Upload generated docs on failure + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: generated-docs-${{ github.run_id }} + path: | + docs/en + docs/zh + retention-days: 7 diff --git a/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md b/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md index f076d3a3f..4565d3aef 100644 --- a/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md +++ b/docs/en/solutions/Pipeline_Policy_Constraints_with_Tekton_and_Kyverno.md @@ -6,6 +6,7 @@ kind: - Solution ProductsVersion: - 4.3.x +id: KB260800021 --- # Pipeline Policy Constraints with Tekton and Kyverno diff --git a/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md b/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md index de5c190ac..e5eb456df 100644 --- a/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md +++ b/docs/zh/solutions/How_to_Migrate_MySQL_57_to_80.md @@ -6,7 +6,6 @@ products: ProductsVersion: - 4.x id: KB260300002 -sourceSHA: 42488d5a3548fdf9f213199c719714baf2fc5c10e533ae99782ddc71a85b77e5 --- # MySQL 5.7 到 8.0 迁移指南 diff --git a/docs/zh/solutions/How_to_Use_PostgreSQL_Hot_Standby_Cluster.md b/docs/zh/solutions/How_to_Use_PostgreSQL_Hot_Standby_Cluster.md index a182b2cff..905bbbfe4 100644 --- a/docs/zh/solutions/How_to_Use_PostgreSQL_Hot_Standby_Cluster.md +++ b/docs/zh/solutions/How_to_Use_PostgreSQL_Hot_Standby_Cluster.md @@ -6,7 +6,6 @@ products: ProductsVersion: - 4.x id: KB251000009 -sourceSHA: 5eae23c900f5a89e26fec5e773b3d048e043b171bab6d6bbf975d3c26b823250 --- # PostgreSQL 热备份集群配置指南 diff --git a/docs/zh/solutions/Install_Multi-Primary_Service_Mesh_on_Different_Networks.md b/docs/zh/solutions/Install_Multi-Primary_Service_Mesh_on_Different_Networks.md index 25aa4ee06..abf1e1e19 100644 --- a/docs/zh/solutions/Install_Multi-Primary_Service_Mesh_on_Different_Networks.md +++ b/docs/zh/solutions/Install_Multi-Primary_Service_Mesh_on_Different_Networks.md @@ -4,7 +4,6 @@ products: - Alauda Service Mesh kind: - Solution -sourceSHA: 2ddecfc3b826a970015660a1889242c353532f50620e1b85d1af72fb9325b3ab --- # 多主不同网络服务网格架构的安装指南 diff --git a/docs/zh/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md b/docs/zh/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md index 0e8deb22f..8c03768bc 100644 --- a/docs/zh/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md +++ b/docs/zh/solutions/Software_Supply_Chain_Security_of_Alauda_Container_Platform_with_Tekton_and_Kyverno.md @@ -4,7 +4,6 @@ products: - Alauda Container Platform kind: - Solution -sourceSHA: 7e3980ae21e998f2fd0014ace7f2cd8b3264c605d2948ce55999536f20eab834 --- ```yaml diff --git a/docs/zh/solutions/ecosystem/opensearch/How_to_Migrate_from_Elasticsearch_to_OpenSearch.md b/docs/zh/solutions/ecosystem/opensearch/How_to_Migrate_from_Elasticsearch_to_OpenSearch.md index de590180f..13d022eec 100644 --- a/docs/zh/solutions/ecosystem/opensearch/How_to_Migrate_from_Elasticsearch_to_OpenSearch.md +++ b/docs/zh/solutions/ecosystem/opensearch/How_to_Migrate_from_Elasticsearch_to_OpenSearch.md @@ -4,7 +4,6 @@ products: kind: - Solution id: KB260100026 -sourceSHA: 4d71480f5d5df1aa4044adeeedaadb443b8cd9b2bd075df00f762f1ec153febe --- # 如何从 Elasticsearch 迁移到 OpenSearch diff --git a/doom.config.ts b/doom.config.ts index 7d0a58aca..e9d29060d 100644 --- a/doom.config.ts +++ b/doom.config.ts @@ -1,28 +1,113 @@ import { defineConfig } from "@alauda/doom/config"; +// Pulls in doom's module augmentation of rspress's UserConfig, which is where +// `translate` is declared. +import type {} from "@alauda/doom/types"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { blogPostResolver } from "./plugins/plugin-post-resolver/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// The default prompt shipped by doom has two properties this repository cannot +// live with, both of which produced real damage in run 33057885704 and before: +// +// 1. It illustrates its rules with literal placeholders -- a link written as +// [text](URL), an image written as ![alt](src). When the model loses the +// thread mid-chunk it recites its own instructions into the prose, and those +// placeholders arrive as real markup: `URL` became a dead link and `src` +// became "Module not found", both of which failed the build. +// 2. It never says the translation has to be complete. It asks for the format +// to be preserved and says nothing about the content, so dropping an entire +// section, 46 code blocks or every table in the document breaks no rule it +// was given. Three articles in this repository lost about a third of their +// content that way, unnoticed, because nothing checked. +// +// So: describe the rules instead of illustrating them with copyable fake values, +// put completeness first, and forbid emitting these instructions outright. +// scripts/check-translation-integrity.mjs is the backstop that catches what slips. +const TRANSLATE_SYSTEM_PROMPT = ` +You are a professional technical documentation engineer. Translate the document below from <%= sourceLang %> into <%= targetLang %>, so that it reads as if the same engineer had written it in <%= targetLang %>, at the same level of precision. + +## What you return + +Return the translated document, and nothing else: no preamble, no closing remark, no code fence wrapped around the whole answer, no notes about what you did. + +Never reproduce any part of these instructions in your output. They are not part of the document. If you find yourself writing a heading such as "Baseline Requirements", a glossary of term mappings, or a note about chunked translation, you have started copying this prompt into the document -- stop and return to translating the source. + +## Completeness comes first + +Your output must contain everything the source contains, in the same order and at the same structural level: every heading, paragraph, list item, table row, fenced code block, admonition, blockquote and footnote. + +Never summarise, merge, abbreviate, or skip a passage, however repetitive or boilerplate it looks. Never stop before the end of the input. A clumsy sentence can be fixed later; a section you silently dropped cannot, because nobody will know it is missing. + +## What must survive unchanged + +- Link destinations. Translate the visible text of a link; reproduce the destination exactly as written, character for character, including any anchor fragment or query string. This applies to inline links, reference definitions, bare URLs, and href or src attributes in HTML and JSX. +- Anchor placeholders. Tokens of the form __ANCHOR_ followed by a number are heading identifiers that the document cross-references. Reproduce every one of them, exactly as written, in the same position. Dropping one silently breaks navigation. +- The contents of fenced code blocks and inline code spans: field names, CLI flags, resource kinds, expressions and regular expressions are code, not prose. +- JSX and MDX component names and their attribute keys; only the content between component tags is translated. +- Escape characters already present in the source, such as backslashes and angle brackets. Do not add escapes that the source does not have -- brackets and parentheses in ordinary prose stay as they are. +- Technical terms and proper nouns that are conventionally left untranslated: product names, Kubernetes and cloud-native project names, language and format names, and API object names. + +## Frontmatter and comments + +- In frontmatter, translate the title and description fields only; leave every other field exactly as it is. +- Preserve these comments and their contents, in both MDX and HTML comment syntax: release-notes-for-bugs. +- Remove these comments entirely, in both MDX and HTML comment syntax: reference-start and reference-end. + +## Language + +Sentences should read naturally to a native <%= targetLang %> speaker and follow the conventions of technical documentation in that language. Keep the register of the source: if it is dense and exact, stay dense and exact rather than smoothing it out. +<% if (titleTranslationPrompt) { %> +<%- titleTranslationPrompt %> +<% } %> +<% if (terms) { %> +<%- terms %> +<% } %> +<% if (isChunk) { %> +## This is one chunk of a longer document + +The text below is a consecutive slice of a larger document, cut at a heading boundary. Translate the whole slice as a continuous part of that document, keeping the style consistent with it. + +Being handed a fragment changes nothing about the rules above. Translate from its first line to its last. Do not introduce it, do not summarise it, do not comment on the fact that it is a fragment, and do not write anything about the chunking itself. +<% } %> +<% if (userPrompt || additionalPrompts) { %> +## Additional requirements + +These apply in addition to everything above; where they conflict with it, everything above wins. + +""" +<% if (userPrompt) { %> +<%- userPrompt %> +<% } %> +<% if (additionalPrompts) { %> +<%- additionalPrompts %> +<% } %> +""" +<% } %> +`.trim() + export default defineConfig({ title: "Alauda Knowledge", - base: "/knowledge/", - description: - "Welcome to Alauda's Knowledgebase information center. Find resources for resolving problems and troubleshooting.", - logo: "/logo.svg", - logoText: "Alauda Knowledge", - globalStyles: join(__dirname, "styles/index.css"), - plugins: [ - blogPostResolver({ - postsDir: join(__dirname, "docs"), - }), - ], - themeConfig: { - darkMode: false, - lastUpdated: true, - footer: { - message: "© 2025 Alauda Inc. All Rights Reserved.", - }, - }, -}); + base: "/knowledge/", + description: + "Welcome to Alauda's Knowledgebase information center. Find resources for resolving problems and troubleshooting.", + logo: "/logo.svg", + logoText: "Alauda Knowledge", + globalStyles: join(__dirname, "styles/index.css"), + plugins: [ + blogPostResolver({ + postsDir: join(__dirname, "docs"), + }), + ], + translate: { + systemPrompt: TRANSLATE_SYSTEM_PROMPT, + }, + themeConfig: { + darkMode: false, + lastUpdated: true, + footer: { + message: "© 2025 Alauda Inc. All Rights Reserved.", + }, + }, +}); diff --git a/package.json b/package.json index 49bbf24a4..7eb466efc 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "new": "doom new", "serve": "doom serve", "translate": "doom translate", + "check:translation": "node scripts/check-translation-integrity.mjs", + "test:translation": "node scripts/check-translation-integrity.test.mjs", "export": "doom export" } } diff --git a/scripts/check-translation-integrity.mjs b/scripts/check-translation-integrity.mjs new file mode 100644 index 000000000..e907b10f0 --- /dev/null +++ b/scripts/check-translation-integrity.mjs @@ -0,0 +1,681 @@ +#!/usr/bin/env node +/** + * Check machine-translated docs against their English originals, and repair the + * damage that is safely repairable. + * + * `doom translate` hands each document to an LLM, cutting anything over 60KB + * into chunks. The model does not always come back with a translation: it has + * rewritten link targets, invented image markup, recited its own prompt into + * the prose, and -- worst, because nothing noticed -- silently dropped entire + * sections. Three documents in this repository were found missing roughly a + * third of their content, having passed every check that existed at the time. + * + * Two kinds of check, because the two failures need opposite treatment: + * + * Structure (headings, anchors, code blocks, table rows) must survive + * translation untouched. When it does not, content was lost or invented and + * the file is reported, never rewritten -- what is missing cannot be + * reconstructed here, and repairing the rest would only hide it. + * + * References (link targets, image srcs) are compared against English, which + * is the ground truth, and with --fix a drifted target is restored or + * invented markup demoted to plain text. + * + * Usage: + * node scripts/check-translation-integrity.mjs [--fix] [--all] [--source en] [--target zh] + * + * (default scope) files under docs/ that git reports as modified or + * untracked -- i.e. the ones translate just (re)wrote + * --all every file under docs/ + * --docs check a docs tree outside this repo (implies a full scan) + * --failures write the failing documents to , one path per line, for + * translate-verified.mjs to hand back to the translator + * + * The missing-translation audit always covers the whole library, whatever the + * scope above: a document translate never produced has no target file to scan, + * so scoping by target would make exactly that failure invisible. + * + * Only internal route links are compared -- the exact set rspress resolves + * against the route table and fails the build over. In-page anchors and + * external URLs are out of scope: a translated heading legitimately gets a + * translated slug, and neither kind is ever resolved by the dead-link check. + */ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const argv = process.argv.slice(2) +const hasFlag = (name) => argv.includes(name) +const flagValue = (name, fallback) => { + const i = argv.indexOf(name) + return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback +} + +const FIX = hasFlag('--fix') +const ALL = hasFlag('--all') +const SOURCE_LANG = flagValue('--source', 'en') +const TARGET_LANG = flagValue('--target', 'zh') +const DOCS_OVERRIDE = flagValue('--docs', '') + +const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '..') +// --docs points the checker at a docs tree outside the repo (used by the tests); +// it also switches off git-based scoping, since that tree is not tracked here. +const docsDir = DOCS_OVERRIDE ? path.resolve(DOCS_OVERRIDE) : path.join(repoRoot, 'docs') +const scanAll = ALL || Boolean(DOCS_OVERRIDE) +const sourceDir = path.join(docsDir, SOURCE_LANG) +const targetDir = path.join(docsDir, TARGET_LANG) + +const DOC_EXTENSIONS = new Set(['.md', '.mdx']) + +/** Recursively collect every markdown file under `dir`. */ +const walk = (dir) => { + const out = [] + if (!fs.existsSync(dir)) return out + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...walk(full)) + else if (DOC_EXTENSIONS.has(path.extname(entry.name))) out.push(full) + } + return out +} + +/** Files translate touched in this working tree (modified, staged or untracked). */ +const changedTargetFiles = () => { + const stdout = execFileSync( + 'git', + // -uall lists untracked files individually. Without it git collapses a new + // directory into one "?? dir/" entry, and a translation landing in a folder + // that did not exist before would skip the check entirely. + ['status', '--porcelain', '-uall', '--', path.relative(repoRoot, targetDir)], + { cwd: repoRoot, encoding: 'utf8' }, + ) + const files = [] + for (const line of stdout.split('\n')) { + if (!line.trim()) continue + // Porcelain v1: two status chars, a space, then the path; renames use "old -> new". + let file = line.slice(3).trim() + const arrow = file.indexOf(' -> ') + if (arrow !== -1) file = file.slice(arrow + 4) + if (file.startsWith('"') && file.endsWith('"')) file = JSON.parse(file) + const abs = path.resolve(repoRoot, file) + if (DOC_EXTENSIONS.has(path.extname(abs)) && fs.existsSync(abs)) files.push(abs) + } + return files +} + +/** + * Blank out everything a markdown link must not be harvested from, keeping the + * offsets of the remaining text intact so match indices stay usable for --fix: + * frontmatter, fenced code blocks and inline code spans. + */ +const maskNonProse = (content) => { + const lines = content.split('\n') + const masked = lines.slice() + let inFrontmatter = false + let fence = null // { char: '`' | '~', length: number } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (i === 0 && /^---\s*$/.test(line)) { + inFrontmatter = true + masked[i] = ' '.repeat(line.length) + continue + } + if (inFrontmatter) { + masked[i] = ' '.repeat(line.length) + if (/^---\s*$/.test(line)) inFrontmatter = false + continue + } + + // Any indentation counts as a fence, not just CommonMark's 0-3 spaces: + // these docs nest fences inside JSX () where remark still reads + // them as fences, and en/zh must be masked identically or the link + // sequences stop lining up. + const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + masked[i] = ' '.repeat(line.length) + // A closing fence is the same character, at least as long, and alone on its line. + if ( + fenceMatch && + fenceMatch[1][0] === fence.char && + fenceMatch[1].length >= fence.length && + /^\s*[`~]+\s*$/.test(line) + ) { + fence = null + } + continue + } + if (fenceMatch) { + fence = { char: fenceMatch[1][0], length: fenceMatch[1].length } + masked[i] = ' '.repeat(line.length) + continue + } + + masked[i] = maskInlineCode(line) + } + + return masked.join('\n') +} + +/** Replace `code span` runs with spaces, preserving length. */ +const maskInlineCode = (line) => { + let out = '' + let i = 0 + while (i < line.length) { + if (line[i] !== '`') { + out += line[i++] + continue + } + let run = 0 + while (line[i + run] === '`') run++ + const delimiter = '`'.repeat(run) + const close = line.indexOf(delimiter, i + run) + if (close === -1) { + // Unterminated span: not a code span at all, keep the backticks as-is. + out += delimiter + i += run + continue + } + const end = close + run + out += ' '.repeat(end - i) + i = end + } + return out +} + +/** Mirrors isExternalUrl / normalizeLink's early returns in @rspress/shared. */ +const isInternalRouteLink = (target) => + target !== '' && + !target.startsWith('#') && + !target.startsWith('http://') && + !target.startsWith('https://') && + !target.startsWith('mailto:') && + !target.startsWith('tel:') && + !/^\s*data:/i.test(target) + +// [text](target "optional title"), with a leading `!` marking an image. +const LINK_RE = /(!?)\[((?:[^[\]\\]|\\.|\[[^[\]]*\])*)\]\(\s*([^()\s]*)((?:\s+"[^"]*")?)\s*\)/g + +/** + * Internal links and images of a document, in order, with offsets into the raw + * text. They are kept apart because only one of them has a stable target: + * normalizeImgSrc rewrites image paths between languages on purpose, so an + * image src that differs is expected, while a link target that differs is not. + */ +const extractRefs = (content) => { + const masked = maskNonProse(content) + const links = [] + const images = [] + for (const match of masked.matchAll(LINK_RE)) { + const target = match[3] + // Only internal route links are compared -- exactly the set rspress + // resolves against the route table and fails the build over. In-page + // anchors legitimately differ (a translated heading gets a translated + // slug) and external URLs are never resolved, so neither is our business; + // both mirror normalizeLink's early returns in @rspress/core. + if (!isInternalRouteLink(target)) continue + // Offsets into the raw document -- `masked` preserves every offset, so the + // indices computed here also address the original content. + // `![` or `[` + text + `](`, then any padding before the target itself. + const afterOpen = match[1].length + 1 + match[2].length + 2 + const padding = /^\s*/.exec(match[0].slice(afterOpen))[0].length + const targetStart = match.index + afterOpen + padding + const ref = { + target, + text: match[2], + start: targetStart, + end: targetStart + target.length, + // The whole `[text](target)` span, needed to demote a hallucinated ref. + linkStart: match.index, + linkEnd: match.index + match[0].length, + line: content.slice(0, match.index).split('\n').length, + } + if (match[1] === '!') images.push(ref) + else links.push(ref) + } + return { links, images } +} + +/** + * Longest common subsequence of two target lists, as index pairs. Equal targets + * are the anchors we trust; everything between two anchors is a gap the caller + * has to make a decision about. + */ +const lcsPairs = (a, b) => { + const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0)) + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + table[i][j] = + a[i].target === b[j].target + ? table[i + 1][j + 1] + 1 + : Math.max(table[i + 1][j], table[i][j + 1]) + } + } + const pairs = [] + let i = 0 + let j = 0 + while (i < a.length && j < b.length) { + if (a[i].target === b[j].target) { + pairs.push([i, j]) + i++ + j++ + } else if (table[i + 1][j] >= table[i][j + 1]) i++ + else j++ + } + return pairs +} + +/** Number of fenced code blocks. Masking erases their content but not their count. */ +const countFences = (content) => { + let fence = null + let n = 0 + for (const line of content.split('\n')) { + const m = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + if (m && m[1][0] === fence.char && m[1].length >= fence.length && /^\s*[`~]+\s*$/.test(line)) fence = null + continue + } + if (m) { + fence = { char: m[1][0], length: m[1].length } + n++ + } + } + return n +} + +/** + * Counts a translation must not change. Wording is the translator's business; + * how many sections, code blocks and table rows a document has is not. + * + * This is the part that would have caught the real damage. A run of gpt-4o-mini + * swallowed an entire section plus 1177 lines of YAML out of one article and + * nothing noticed, because the only thing the build checks is whether links + * resolve -- and they did. Two more articles already in the repository turned + * out to be missing a third of their content the same way. + */ +const documentStructure = (content) => { + const masked = maskNonProse(content) + const lines = masked.split('\n') + return { + anchors: (masked.match(/\{#[a-z0-9-]+\}/g) || []), + // Levels rather than text: the text is translated, the shape is not. + headingLevels: lines.flatMap((l) => { + const m = /^(#{1,6}) /.exec(l) + return m ? [m[1].length] : [] + }), + tableRows: lines.filter((l) => l.trimStart().startsWith('|')).length, + codeBlocks: countFences(content), + // Volume and identifiers: what a translation that silently drops prose + // cannot fake. Wording is the translator's business; how much text there is, + // and which numbers it carries, is not. + proseLines: lines.filter((l) => l.trim()).length, + // URLs are excluded: their digits belong to the link, which is checked + // separately, and the two languages legitimately spell the same link + // differently -- [http://x](http://x) carries the number twice where the + // autolink carries it once. + numbers: masked.replace(/ { + if (source.length === 0) return 1 + const pool = new Map() + for (const n of target) pool.set(n, (pool.get(n) || 0) + 1) + let kept = 0 + for (const n of source) { + const left = pool.get(n) || 0 + if (left > 0) { + pool.set(n, left - 1) + kept++ + } + } + return kept / source.length +} + +/** Structural differences, phrased so the reader can see what went missing. */ +const structuralProblems = (source, target) => { + const problems = [] + const s = documentStructure(source) + const t = documentStructure(target) + + if (s.anchors.join('\u0000') !== t.anchors.join('\u0000')) { + const missing = s.anchors.filter((a) => !t.anchors.includes(a)) + const extra = t.anchors.filter((a) => !s.anchors.includes(a)) + problems.push( + `heading anchors differ (${t.anchors.length} vs ${s.anchors.length})` + + (missing.length ? ` -- missing ${[...new Set(missing)].slice(0, 8).join(' ')}` : '') + + (extra.length ? ` -- unexpected ${[...new Set(extra)].slice(0, 8).join(' ')}` : '') + + (!missing.length && !extra.length ? ' -- same set, different order' : ''), + ) + } + if (s.codeBlocks !== t.codeBlocks) { + problems.push(`fenced code blocks ${t.codeBlocks} vs ${s.codeBlocks} -- ${Math.abs(s.codeBlocks - t.codeBlocks)} ${t.codeBlocks < s.codeBlocks ? 'lost' : 'invented'}`) + } + if (s.headingLevels.join(',') !== t.headingLevels.join(',')) { + problems.push(`heading outline differs (${t.headingLevels.length} headings vs ${s.headingLevels.length})`) + } + if (s.tableRows !== t.tableRows) { + problems.push(`table rows ${t.tableRows} vs ${s.tableRows} -- ${Math.abs(s.tableRows - t.tableRows)} ${t.tableRows < s.tableRows ? 'lost' : 'invented'}`) + } + + // Dropped prose keeps every count above intact -- a swallowed paragraph takes + // no heading, no code fence and no table row with it. What it does take is + // volume and the digits that were in it. Both signals must fall together + // before this fires: Chinese legitimately merges English lines (healthy pages + // go as low as 0.33), and rewording legitimately loses the odd number, but no + // healthy page in this repository does both at once. Measured over all 401 + // en/zh pairs at the time of writing: 396 healthy pages pass, and the five + // documents an earlier run damaged are all caught. + const lineRatio = s.proseLines ? t.proseLines / s.proseLines : 1 + const kept = numbersKept(s.numbers, t.numbers) + if (lineRatio < 0.9 && kept < 0.9) { + problems.push( + `prose volume is ${Math.round(lineRatio * 100)}% of the original and ${Math.round((1 - kept) * 100)}% of its numbers are gone` + + ' -- text was dropped rather than translated', + ) + } + return problems +} + +/** + * Does an image src point at a file that actually exists? This is the same + * question rspack asks, and the reason a bogus src fails the build with + * "Module not found". Absolute srcs are served out of docs/public. + */ +const imageResolves = (src, fileDir) => { + const clean = src.split('#')[0].split('?')[0] + if (!clean) return false + const candidate = clean.startsWith('/') + ? path.join(docsDir, 'public', clean.slice(1)) + : path.resolve(fileDir, clean) + return fs.existsSync(candidate) +} + +/** + * Images cannot be aligned against English the way links are: normalizeImgSrc + * rewrites their paths on purpose, so the two sides legitimately disagree and + * there is no stable value to anchor on. Judge them on their own terms instead + * -- a src that resolves to a file is fine however much it differs from the + * English one, and a src that resolves to nothing is the model inventing markup + * (it copied its own prompt's `![alt](src)` example into the prose once), which + * is exactly what breaks the build. Demote those to their alt text. + */ +const planImageEdits = (targetImages, fileDir) => { + const edits = [] + const unresolved = [] + + for (const image of targetImages) { + if (imageResolves(image.target, fileDir)) continue + edits.push({ + kind: 'demote', + label: 'image', + start: image.linkStart, + end: image.linkEnd, + replacement: image.text, + line: image.line, + from: image.target, + to: null, + }) + } + + // Same reasoning as the link cap: a flood means the check is wrong, not the + // translation, and stripping markup wholesale would be worse than failing. + const cap = Math.max(3, Math.floor(targetImages.length * 0.2)) + if (edits.length > cap) { + unresolved.push(`${edits.length} unresolvable image(s) exceeds the cap of ${cap} -- refusing to strip that many`) + } + return { edits, unresolved } +} + +/** + * Decide what to do with every translated reference, English being the truth. + * + * Anchoring on the references both sides agree on leaves gaps, and each gap + * shape says something different about what the model did: + * same count -> it rewrote targets in place; restore them positionally. + * Position is only trustworthy inside a gap this narrow: + * the anchors on both sides of it agree, so the k-th + * unmatched target here is the k-th one there. + * nothing in en -> it invented references that have no original; strip the + * markup and keep the text (a target we cannot source + * from English is a target we must not guess at). + * fewer in zh -> it dropped references; there is no sound place to put + * them back, so the file is left for a human. + * A mixed gap (some English refs, but more on the translated side) is ambiguous + * in the same way, and is refused too. + */ +const planEdits = (sourceLinks, targetLinks, { label }) => { + const edits = [] + const unresolved = [] + const anchors = [...lcsPairs(sourceLinks, targetLinks), [sourceLinks.length, targetLinks.length]] + let si = 0 + let ti = 0 + + for (const [sEnd, tEnd] of anchors) { + const srcGap = sourceLinks.slice(si, sEnd) + const tgtGap = targetLinks.slice(ti, tEnd) + + if (srcGap.length === tgtGap.length) { + for (const [k, link] of tgtGap.entries()) { + edits.push({ + kind: 'restore', + label, + start: link.start, + end: link.end, + replacement: srcGap[k].target, + line: link.line, + from: link.target, + to: srcGap[k].target, + }) + } + } else if (srcGap.length === 0) { + for (const link of tgtGap) { + edits.push({ + kind: 'demote', + label, + start: link.linkStart, + end: link.linkEnd, + replacement: link.text, + line: link.line, + from: link.target, + to: null, + }) + } + } else { + unresolved.push( + `${tgtGap.length} translated ${label}(s) against ${srcGap.length} English one(s)` + + ` around line ${(tgtGap[0] ?? srcGap[0]).line}` + + ` [${tgtGap.map((l) => l.target).join(', ') || '-'}] vs [${srcGap.map((l) => l.target).join(', ')}]`, + ) + } + + si = sEnd + 1 + ti = tEnd + 1 + } + + // Demoting is the one edit that removes markup rather than correcting it. + // A handful is a model slip; a flood means the comparison itself is off. + const demotions = edits.filter((edit) => edit.kind === 'demote') + const demotionCap = Math.max(3, Math.floor(sourceLinks.length * 0.2)) + if (demotions.length > demotionCap) { + unresolved.push( + `${demotions.length} invented ${label}(s) exceeds the cap of ${demotionCap} -- refusing to strip that many`, + ) + } + + return { edits, unresolved } +} + +const relative = (file) => path.relative(repoRoot, file) + +/** + * Does this source document opt out of machine translation? doom skips any page + * whose frontmatter carries `i18n.disableAutoTranslation: true` (see + * @alauda/doom `lib/cli/translate.js`), and a page it was told to skip must not + * then be reported as a missing translation. + * + * Parsed by hand rather than with a YAML library: this script has no + * dependencies, and one boolean nested one level deep does not justify adding + * one. Only the leading frontmatter block is examined. + */ +const disablesAutoTranslation = (content) => { + const match = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(content) + if (!match) return false + let inI18n = false + for (const raw of match[1].split('\n')) { + const line = raw.replace(/\r$/, '') + // A key at column 0 ends the previous block and starts whatever is next. + if (/^\S/.test(line)) inI18n = /^i18n\s*:/.test(line) + else if (inI18n && /^\s+disableAutoTranslation\s*:\s*true\s*$/.test(line)) return true + } + return false +} + +let pass = 0 +let fail = 0 +const failures = [] +let repaired = 0 + +/** + * Every source document that should have been translated, and was not. + * + * This is deliberately outside the scope rules above. The scan below is driven + * by the files under docs/, so a document translate never produced is + * invisible to it -- it neither passes nor fails, and the run reports success + * with the page missing from the site. That is the shape of the outage this + * whole script exists for, so it is checked separately and always in full. + */ +const missingTranslations = [] +for (const sourceFile of walk(sourceDir).sort()) { + const targetFile = path.join(targetDir, path.relative(sourceDir, sourceFile)) + if (fs.existsSync(targetFile)) continue + if (disablesAutoTranslation(fs.readFileSync(sourceFile, 'utf8'))) { + console.log(`SKIP ${relative(sourceFile)} (i18n.disableAutoTranslation)`) + continue + } + fail++ + // Named by its expected target path: that is what the retry globs are + // computed from, and it is where the missing document belongs. + failures.push(relative(targetFile)) + missingTranslations.push(relative(targetFile)) + console.log(`FAIL ${relative(targetFile)} was never produced from ${relative(sourceFile)} -- retranslate`) +} + +const targetFiles = (scanAll ? walk(targetDir) : changedTargetFiles()).filter((file) => + file.startsWith(targetDir + path.sep), +) + +if (targetFiles.length === 0 && missingTranslations.length === 0) { + console.log(`no ${TARGET_LANG} documents to check (${scanAll ? 'full scan' : 'changed files only'})`) + console.log('== result: 0 pass / 0 fail ==') + process.exit(0) +} + +for (const file of targetFiles.sort()) { + const sourceFile = path.join(sourceDir, path.relative(targetDir, file)) + if (!fs.existsSync(sourceFile)) { + // translate removes orphan target files itself; nothing to compare against. + console.log(`SKIP ${relative(file)} (no ${SOURCE_LANG} counterpart)`) + continue + } + + const sourceContent = fs.readFileSync(sourceFile, 'utf8') + let content = fs.readFileSync(file, 'utf8') + + // Shape before wording. If whole sections are missing, no amount of link + // repair makes the document publishable, and repairing it anyway would only + // make the damage quieter. + const structural = structuralProblems(sourceContent, content) + if (structural.length > 0) { + fail++ + failures.push(relative(file)) + console.log(`FAIL ${relative(file)} does not match ${relative(sourceFile)}:`) + for (const problem of structural) console.log(` ${problem}`) + console.log(' translation lost or invented content -- retranslate; this is not repairable here') + continue + } + + const source = extractRefs(sourceContent) + let target = extractRefs(content) + + const describe = (edit) => { + const where = `${relative(file)}:${edit.line}` + if (edit.kind === 'restore') return `${where} "${edit.from}" -> "${edit.to}"` + return edit.label === 'image' + ? `${where} "${edit.from}" resolves to no file -- image syntax stripped, alt text kept` + : `${where} "${edit.from}" has no English original -- link syntax stripped, text kept` + } + + const linkPlan = planEdits(source.links, target.links, { label: 'link' }) + const imagePlan = planImageEdits(target.images, path.dirname(file)) + const edits = [...linkPlan.edits, ...imagePlan.edits] + const unresolved = [...linkPlan.unresolved, ...imagePlan.unresolved] + + // An unresolved gap makes the whole alignment suspect, so nothing is written: + // a partially repaired file is harder to reason about than an untouched one. + if (unresolved.length > 0) { + fail++ + failures.push(relative(file)) + console.log( + `FAIL ${relative(file)} ${target.links.length} link(s) / ${target.images.length} image(s) against` + + ` ${source.links.length} / ${source.images.length} in ${relative(sourceFile)} -- repair by hand:`, + ) + for (const problem of unresolved) console.log(` ${problem}`) + continue + } + + if (edits.length === 0) { + pass++ + console.log(`PASS ${relative(file)} (${source.links.length} links, ${source.images.length} images)`) + continue + } + + if (!FIX) { + fail++ + failures.push(relative(file)) + console.log(`FAIL ${relative(file)} ${edits.length} drifted reference(s):`) + for (const edit of edits) console.log(` ${describe(edit)}`) + continue + } + + // Apply back-to-front so earlier offsets stay valid. + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end) + } + fs.writeFileSync(file, content) + repaired += edits.length + + const after = extractRefs(content) + const converged = + after.links.length === source.links.length && + source.links.every((link, i) => link.target === after.links[i].target) && + after.images.every((image) => imageResolves(image.target, path.dirname(file))) + if (!converged) { + fail++ + failures.push(relative(file)) + console.log(`FAIL ${relative(file)} repair did not converge, inspect by hand`) + continue + } + + pass++ + console.log(`PASS ${relative(file)} repaired ${edits.length} reference(s):`) + for (const edit of edits) console.log(` ${describe(edit)}`) +} + +if (FIX && repaired > 0) { + console.log(`repaired ${repaired} reference(s) against ${SOURCE_LANG}`) +} +console.log(`== result: ${pass} pass / ${fail} fail ==`) + +// Every document that failed, whatever the reason -- content lost, links that +// could not be aligned, a repair that did not converge, a translation never +// produced. None of it is fixable here, but all of it is retryable: +// translate-verified.mjs reads this list and hands just these files back to the +// translator rather than redoing the whole library. +const failureList = flagValue('--failures', '') +if (failureList) fs.writeFileSync(failureList, failures.map((f) => f + '\n').join('')) +process.exit(fail > 0 ? 1 : 0) diff --git a/scripts/check-translation-integrity.test.mjs b/scripts/check-translation-integrity.test.mjs new file mode 100644 index 000000000..ab0eb7739 --- /dev/null +++ b/scripts/check-translation-integrity.test.mjs @@ -0,0 +1,323 @@ +#!/usr/bin/env node +/** + * Tests for check-translation-integrity.mjs. + * + * Every case builds a throwaway docs tree, runs the real script against it with + * --docs, and asserts on what it printed, what it exited with, and what it left + * on disk. Nothing is mocked: the thing under test is the same file CI runs. + * + * The cases are the failures actually seen in production -- a link target + * rewritten to the literal `URL` from the translation prompt, an invented + * image, a section silently dropped, a page never translated at all -- plus the + * things that legitimately differ between languages and must be left alone. + * + * Usage: node scripts/check-translation-integrity.test.mjs + */ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const checker = path.join(scriptDir, 'check-translation-integrity.mjs') + +let pass = 0 +let fail = 0 + +const ok = (condition, label, detail = '') => { + if (condition) { + pass++ + console.log(`PASS ${label}`) + } else { + fail++ + console.log(`FAIL ${label}${detail ? `\n ${detail}` : ''}`) + } +} + +/** A docs tree with one en/zh pair, plus whatever extra files a case needs. */ +const makeTree = ({ en, zh, extra = {} }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'translation-check-')) + for (const [lang, files] of Object.entries({ en, zh })) { + for (const [name, content] of Object.entries(files || {})) { + const file = path.join(root, lang, name) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, content) + } + } + for (const [name, content] of Object.entries(extra)) { + const file = path.join(root, name) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, content) + } + return root +} + +const check = (root, ...args) => { + const run = spawnSync('node', [checker, '--docs', root, ...args], { encoding: 'utf8' }) + return { status: run.status, out: `${run.stdout}${run.stderr}` } +} + +const read = (root, lang, name) => fs.readFileSync(path.join(root, lang, name), 'utf8') + +const FRONTMATTER = '---\nid: KB1\n---\n' + +// --------------------------------------------------------------------------- +// A clean pair, and the kinds of difference that are not drift. +// --------------------------------------------------------------------------- +{ + const en = `${FRONTMATTER}# Title {#title} + +See [the guide](./guide.md) and [the site](https://example.com/en) and [above](#title). + +\`\`\`bash +curl "[not](a-link)" +\`\`\` + +Inline \`[not](a-link)\` too. +` + const zh = `${FRONTMATTER}# 标题 {#title} + +参见 [指南](./guide.md) 和 [站点](https://example.com/en) 以及 [上文](#标题)。 + +\`\`\`bash +curl "[不是](链接)" +\`\`\` + +行内 \`[不是](链接)\` 同样。 +` + const root = makeTree({ en: { 'a.md': en }, zh: { 'a.md': zh } }) + const before = read(root, 'zh', 'a.md') + const { status, out } = check(root, '--fix') + ok(status === 0, 'clean pair exits 0', out) + ok(out.includes('== result: 1 pass / 0 fail =='), 'clean pair counts one pass', out) + ok(read(root, 'zh', 'a.md') === before, 'clean pair is not rewritten') + ok(!out.includes('#标题'), 'translated in-page anchor is not compared') + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// The production failure: the model recites its own prompt and the target +// becomes the literal string URL. +// --------------------------------------------------------------------------- +{ + const en = `${FRONTMATTER}# T + +[one](./one.md) then [two](./two.md). +` + const zhBroken = `${FRONTMATTER}# T + +[一](URL) 然后 [二](./two.md)。 +` + const zhGood = zhBroken.replace('(URL)', '(./one.md)') + const root = makeTree({ en: { 'a.md': en }, zh: { 'a.md': zhBroken } }) + + const detect = check(root) + ok(detect.status === 1, 'drifted target fails without --fix', detect.out) + ok( + detect.out.includes('"URL" -> "./one.md"'), + 'drifted target names the old and the new value', + detect.out, + ) + ok(read(root, 'zh', 'a.md') === zhBroken, 'no --fix leaves the file untouched') + + const repair = check(root, '--fix') + ok(repair.status === 0, 'drifted target is repaired', repair.out) + ok(read(root, 'zh', 'a.md') === zhGood, 'repair restores the English target byte for byte') + + const again = check(root, '--fix') + ok(again.status === 0 && read(root, 'zh', 'a.md') === zhGood, 'repair is idempotent', again.out) + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// Fences nested in JSX, and frontmatter that looks like markup. +// --------------------------------------------------------------------------- +{ + const body = (linkText) => `--- +pattern: ^feat[/-](?.+) +--- +# T + + + + + \`\`\`yaml + ref: "[x](SHOULD-NOT-BE-SEEN)" + \`\`\` + + + + +[${linkText}](./real.md) +` + const root = makeTree({ en: { 'a.md': body('real') }, zh: { 'a.md': body('真实') } }) + const { status, out } = check(root, '--fix') + ok(status === 0, 'JSX-nested fence and frontmatter do not break the scan', out) + ok(!out.includes('SHOULD-NOT-BE-SEEN'), 'link inside an indented fence is masked out', out) + ok(out.includes('(1 links, 0 images)'), 'only the prose link is counted', out) + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// Gaps that cannot be aligned: refuse, do not guess. +// --------------------------------------------------------------------------- +{ + const en = `${FRONTMATTER}# T + +[one](./one.md), [two](./two.md), [three](./three.md). +` + const zhFewer = `${FRONTMATTER}# T + +[一](./one.md), 二, [三](./three.md)。 +` + const root = makeTree({ en: { 'a.md': en }, zh: { 'a.md': zhFewer } }) + const { status, out } = check(root, '--fix') + ok(status === 1, 'a dropped link fails', out) + ok(out.includes('repair by hand'), 'a dropped link is handed to a human', out) + ok(read(root, 'zh', 'a.md') === zhFewer, 'a dropped link leaves the file untouched') + fs.rmSync(root, { recursive: true, force: true }) +} + +{ + const en = `${FRONTMATTER}# T + +[one](./one.md), [two](./two.md), [three](./three.md). +` + // Same set, two of them swapped: positional repair here would silently point + // each link at the other one's target, so the file must be refused instead. + const zhSwapped = `${FRONTMATTER}# T + +[一](./one.md), [二](./three.md), [三](./two.md)。 +` + const root = makeTree({ en: { 'a.md': en }, zh: { 'a.md': zhSwapped } }) + const { status } = check(root, '--fix') + ok(status === 1, 'reordered links fail rather than being rewritten') + ok(read(root, 'zh', 'a.md') === zhSwapped, 'reordered links leave the file untouched') + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// Images are judged on whether they resolve, not against English. +// --------------------------------------------------------------------------- +{ + const en = `${FRONTMATTER}# T + +![shot](./shot.png) +` + const zh = `${FRONTMATTER}# T + +![截图](./shot.png) + +![说明](src) +` + const root = makeTree({ + en: { 'a.md': en }, + zh: { 'a.md': zh, 'shot.png': 'x' }, + }) + fs.writeFileSync(path.join(root, 'en', 'shot.png'), 'x') + const { status, out } = check(root, '--fix') + ok(status === 0, 'an unresolvable image is repaired', out) + const after = read(root, 'zh', 'a.md') + ok(after.includes('![截图](./shot.png)'), 'a resolvable image is left alone') + ok(after.includes('\n说明\n') || after.includes('\n说明'), 'an invented image is demoted to its alt text', after) + ok(!after.includes('](src)'), 'the invented image markup is gone', after) + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// Content loss: reported, never rewritten. +// --------------------------------------------------------------------------- +{ + const en = `${FRONTMATTER}# T + +\`\`\`bash +one +\`\`\` + +\`\`\`bash +two +\`\`\` + +[link](./a.md) +` + const zh = `${FRONTMATTER}# T + +\`\`\`bash +one +\`\`\` + +[链接](URL) +` + const root = makeTree({ en: { 'a.md': en }, zh: { 'a.md': zh } }) + const { status, out } = check(root, '--fix') + ok(status === 1, 'a lost code block fails', out) + ok(out.includes('fenced code blocks 1 vs 2 -- 1 lost'), 'the lost code block is named', out) + ok(read(root, 'zh', 'a.md') === zh, 'a document that lost content is never rewritten') + ok( + !out.includes('"URL" ->'), + 'link repair does not run on a document that lost content', + out, + ) + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// A page that was never translated at all -- invisible to a target-driven scan. +// --------------------------------------------------------------------------- +{ + const root = makeTree({ + en: { + 'a.md': `${FRONTMATTER}# A\n`, + 'never.md': `${FRONTMATTER}# Never\n`, + 'optout.md': '---\nid: KB2\ni18n:\n disableAutoTranslation: true\n---\n# Opt out\n', + }, + zh: { 'a.md': `${FRONTMATTER}# A\n` }, + }) + const { status, out } = check(root, '--fix') + ok(status === 1, 'a missing translation fails', out) + ok(out.includes('never.md was never produced'), 'the missing translation is named', out) + ok(out.includes('optout.md (i18n.disableAutoTranslation)'), 'an opted-out page is skipped', out) + ok(!out.includes('optout.md was never produced'), 'an opted-out page is not a failure', out) + fs.rmSync(root, { recursive: true, force: true }) +} + +// --------------------------------------------------------------------------- +// --failures must name every failing document: translate-verified.mjs retries +// exactly what is on that list, so anything missing from it is never retried. +// --------------------------------------------------------------------------- +{ + const root = makeTree({ + en: { + 'lost.md': `${FRONTMATTER}# T\n\n\`\`\`bash\na\n\`\`\`\n\n\`\`\`bash\nb\n\`\`\`\n`, + 'gap.md': `${FRONTMATTER}# T\n\n[one](./one.md), [two](./two.md).\n`, + 'never.md': `${FRONTMATTER}# Never\n`, + 'fine.md': `${FRONTMATTER}# T\n`, + }, + zh: { + 'lost.md': `${FRONTMATTER}# T\n\n\`\`\`bash\na\n\`\`\`\n`, + 'gap.md': `${FRONTMATTER}# T\n\n[一](./one.md), 二。\n`, + 'fine.md': `${FRONTMATTER}# T\n`, + }, + }) + const listFile = path.join(root, 'failures.txt') + const { status } = check(root, '--fix', '--failures', listFile) + const listed = fs + .readFileSync(listFile, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => path.basename(line)) + .sort() + ok(status === 1, 'a mixed run of failures exits 1') + ok( + JSON.stringify(listed) === JSON.stringify(['gap.md', 'lost.md', 'never.md']), + 'every failing document reaches --failures, whatever the reason', + `got ${JSON.stringify(listed)}`, + ) + ok(!listed.includes('fine.md'), 'a healthy document is not listed for retranslation') + fs.rmSync(root, { recursive: true, force: true }) +} + +console.log(`== result: ${pass} pass / ${fail} fail ==`) +process.exit(fail > 0 ? 1 : 0) diff --git a/scripts/translate-verified.mjs b/scripts/translate-verified.mjs new file mode 100644 index 000000000..56e0347fb --- /dev/null +++ b/scripts/translate-verified.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Translate the documentation, then prove the result is complete -- and + * retranslate the documents that are not. + * + * A single translation pass is not trustworthy on its own. The model that + * produces these pages has swallowed an entire section plus 1177 lines of YAML + * out of one article, invented a table into another, and left a third short of + * a heading even on a second attempt. None of that failed the build, because + * the build only asks whether links resolve. + * + * So the pass is no longer the deliverable -- a verified pass is. Translate, + * check, and hand the failing documents back to the translator with --force so + * only they are redone. The run fails only if a document is still incomplete + * after every attempt, and in that case nothing is committed: the workflow + * stops before the build, so a short page never reaches the site. + * + * Environment: + * TRANSLATE_ATTEMPTS how many times to try a document (default 3) + * TRANSLATE_CMD the translate command (default "yarn translate"), so + * the retry loop can be exercised without an API key + */ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const ATTEMPTS = Number(process.env.TRANSLATE_ATTEMPTS || 3) +const SOURCE = process.env.TRANSLATE_SOURCE || 'en' +const TARGET = process.env.TRANSLATE_TARGET || 'zh' +const TRANSLATE = (process.env.TRANSLATE_CMD || 'yarn translate').split(' ') +// The check reports paths under the docs root; --docs moves that root, and the +// retry globs are relative to it, so both have to agree on where it is. +const docsArg = process.argv.indexOf('--docs') +const DOCS = docsArg === -1 ? 'docs' : process.argv[docsArg + 1] + +const failuresFile = path.join(os.tmpdir(), `translation-failures-${process.pid}.txt`) + +const run = (cmd, args) => { + console.log(`\n$ ${cmd} ${args.join(' ')}`) + return spawnSync(cmd, args, { stdio: 'inherit' }).status +} + +const translate = (globs) => + run(TRANSLATE[0], [ + ...TRANSLATE.slice(1), + '-s', SOURCE, + '-t', TARGET, + '-g', ...globs, + // Retries target documents that already have a translation on disk; without + // --force doom skips them, because their sourceSHA still matches. + ...(globs[0] === '*' ? [] : ['--force']), + ]) + +/** + * Run the integrity check. --fix repairs what is repairable (link and image + * markup the model invented); documents that lost or gained content come back + * as failures, because no rewrite here can put back text that is gone. + */ +const verify = () => { + fs.rmSync(failuresFile, { force: true }) + const status = run('node', [ + 'scripts/check-translation-integrity.mjs', + '--fix', + '--failures', failuresFile, + // Anything passed to this script is forwarded to the check, which is how + // the loop is exercised against a scratch docs tree in the tests. + ...process.argv.slice(2), + ]) + const failures = fs.existsSync(failuresFile) + ? fs.readFileSync(failuresFile, 'utf8').split('\n').filter(Boolean) + : [] + return { ok: status === 0, failures } +} + +if (translate(['*']) !== 0) { + console.error('\ntranslation command failed -- not retrying, this is not a content problem') + process.exit(1) +} + +let outstanding = [] +for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + const { ok, failures } = verify() + if (ok) { + console.log(`\ntranslation verified on attempt ${attempt} of ${ATTEMPTS}`) + process.exit(0) + } + outstanding = failures + if (!failures.length) { + // Every document-level failure names its document, so an empty list means + // the check itself did not get that far -- it crashed, or could not read + // the tree. Retranslating would not change that. + console.error('\nintegrity check failed without naming a document -- see the output above') + process.exit(1) + } + if (attempt === ATTEMPTS) break + console.log(`\nattempt ${attempt} of ${ATTEMPTS}: ${failures.length} document(s) incomplete, retranslating just those`) + for (const f of failures) console.log(` ${f}`) + const globs = failures.map((f) => path.relative(path.join(DOCS, TARGET), f)) + if (translate(globs) !== 0) { + console.error('\nretranslation command failed') + process.exit(1) + } +} + +console.error(`\n${outstanding.length} document(s) still incomplete after ${ATTEMPTS} attempts:`) +for (const f of outstanding) console.error(` ${f}`) +console.error('\nNothing is committed. Fix these by hand, or raise TRANSLATE_ATTEMPTS, before the site is rebuilt.') +process.exit(1)