From e126a81ce5b14d5397a8a01593ce96b1b5979573 Mon Sep 17 00:00:00 2001 From: Chancel WOROU Date: Mon, 21 Sep 2026 03:44:19 +0100 Subject: [PATCH 1/3] feat(i18n): add translation drift check script Add scripts/i18n-drift-check.mjs, the drift checker referred to by the Translation Guidelines. Each translated page records the git hash of the English page it was made from (i18n_source_sha); a translation is reported as stale when that hash no longer matches. Includes the test suite, the i18n:* npm scripts and the .gitignore entries for the generated reports. Report only: nothing blocks a PR, and no documentation page is modified. Relates to #592 Co-authored-by: MaximeNzo <58881605+MaximeNzo@users.noreply.github.com> Co-authored-by: Nounagnon02 <171674631+Nounagnon02@users.noreply.github.com> --- .gitignore | 4 + package.json | 6 + scripts/i18n-drift-check.mjs | 1181 +++++++++++++++++++++++++++++ scripts/i18n-drift-check.test.mjs | 370 +++++++++ 4 files changed, 1561 insertions(+) create mode 100644 scripts/i18n-drift-check.mjs create mode 100644 scripts/i18n-drift-check.test.mjs diff --git a/.gitignore b/.gitignore index 5bca1818b..e8716beda 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ basement_dist devspace* .devspace/**.* +# i18n drift report (generated artifact, do not commit) +i18n-report.json +i18n-report.md + # Add ignores *IGNORE* *ignore* diff --git a/package.json b/package.json index e59b0c95f..56bb22046 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,12 @@ "scripts": { "dev": "NODE_OPTIONS=--openssl-legacy-provider npx npx vuepress dev docs", "build": "NODE_OPTIONS='--max-old-space-size=8192' npx npx vuepress build docs", + "i18n:check": "node scripts/i18n-drift-check.mjs --base origin/master --working-tree --markdown-out i18n-report.md --json-out i18n-report.json", + "i18n:audit": "node scripts/i18n-drift-check.mjs --audit", + "i18n:baseline": "node scripts/i18n-drift-check.mjs --audit --update-baseline", + "i18n:stamp": "node scripts/i18n-drift-check.mjs --stamp", + "i18n:explain": "node scripts/i18n-drift-check.mjs --explain", + "i18n:test": "node --test scripts/i18n-drift-check.test.mjs", "build:plantuml:all": "./scripts/_build_plantuml.sh", "build:plantuml:diff": "MODE=STAGED_GIT ./scripts/_build_plantuml.sh", "lint": "npx markdownlint './docs/**/*.md' --ignore node_modules --config markdownlint.yaml", diff --git a/scripts/i18n-drift-check.mjs b/scripts/i18n-drift-check.mjs new file mode 100644 index 000000000..e6333e6da --- /dev/null +++ b/scripts/i18n-drift-check.mjs @@ -0,0 +1,1181 @@ +#!/usr/bin/env node + +/***** + License + -------------- + Copyright ยฉ 2026 Mojaloop Foundation + + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 + (the "License") and you may not use these files except in compliance with the [License](http://www.apache.org/licenses/LICENSE-2.0). + + You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the [License](http://www.apache.org/licenses/LICENSE-2.0). + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + + * MaximeNzo + * Chancel WOROU + * Nounagnon02 + + -------------- + ******/ + +/** + * NOTES: + * - ESM module (thus the extension `.mjs`), since package.json does not set `"type": "module"`. + * - Detects translation drift between the English source docs under `docs/` and their + * localized copies under `docs//`. + * + * HOW "UP TO DATE" IS DECIDED - read this first, it is the heart of the script. + * + * Every translated page carries a HASH of the English page it was made from, stored in its + * front matter as `i18n_source_sha`. The hash is git's own blob hash of the source file. + * + * docs/fr/technical/guide.md + * --- + * i18n_source_sha: 7e45a52f21d78bfc6c6233abd7e8b2a73b007a10 <- hash of docs/technical/guide.md + * --- + * + * To judge a translation we recompute the hash of the current English file and compare: + * + * recorded hash === current hash -> the translation matches this exact English text + * recorded hash !== current hash -> the source moved on; the translation is behind + * + * Why a hash and not "was the file edited in this change?" - the question the script used + * to ask. That older test was about the GESTURE, not the CONTENT: adding a stray comma to + * the French page made it look up to date even when the English edit was never carried + * across. A hash cannot be fooled that way, because nothing you do inside the translated + * file changes the hash of the English one. + * + * A hash also survives what timestamps do not. The previous staleness check compared git + * commit dates, which a squash or a rebase rewrites - and indeed the whole French + * translation landed in one squashed commit, so every page shares a date and the check + * detected nothing. Content hashing is immune to both. + * + * Cost of the approach: the hash is only true if translators refresh it (`npm run + * i18n:stamp`). A page whose hash was never written is reported as `unstamped` rather than + * silently trusted - see judgeTranslation(). + * + * Two reporting modes: + * (default) diff mode - inspects a git range. Used by PR / push CI. With + * --working-tree the range ends at the files on disk, which is + * what `npm run i18n:check` uses on a contributor's machine. + * --audit - scans the whole working tree. Used for the standing backlog, + * and the only mode that reports coverage. + * + * Two translator tools: + * --stamp - records source hashes. The only mode that edits documentation. + * --explain - shows what changed in the source since was translated. + * + * Exit codes: + * 0 clean (or report-only) + * 1 usage or configuration error + * 2 drift found outside of the baseline, and --fail-on asked us to block + * + * Design notes and the rationale behind each rule: ./i18n-drift-check.md + */ + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; + +const DOCS_DIR = "docs"; +const CONFIG_PATH = "docs/.vuepress/config.js"; +const BASELINE_PATH = "scripts/i18n-baseline.json"; + +// Front-matter key under which a translated page records the HASH of its English source. +// Written by --stamp, read by every verdict. Because it is a hash of the source file, no +// edit made inside the translated file can change it - which is precisely what stops a +// cosmetic tweak from passing as a real translation. +const STAMP_KEY = "i18n_source_sha"; +const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +// A locale directory is a language tag: `fr`, `pt`, `pt-BR`, `zh-Hans`. +// Content directories (`adoption`, `getting-started`, ...) never match this. +const LOCALE_DIR_PATTERN = /^[a-z]{2}(-[A-Za-z]{2,4})?$/; + +const STATUS_META = { + missing: { emoji: "โŒ", label: "translation does not exist" }, + orphaned: { emoji: "๐Ÿ—‘๏ธ", label: "translation exists but its source is gone" }, + stale: { emoji: "โš ๏ธ", label: "translation records an older version of the source" }, + needs_review: { emoji: "โš ๏ธ", label: "source changed, and the unstamped translation was not touched" }, + updated: { emoji: "โœ…", label: "translation matches the current source" }, + unstamped: { emoji: "ยท", label: `translation carries no ${"`i18n_source_sha`"} yet` }, +}; + +const ALL_DRIFT_STATUSES = ["missing", "orphaned", "stale", "needs_review"]; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const current = argv[i]; + if (!current.startsWith("--")) continue; + + // Support both `--key value` and `--key=value`. + const equalsIndex = current.indexOf("="); + if (equalsIndex !== -1) { + args[current.slice(2, equalsIndex)] = current.slice(equalsIndex + 1); + continue; + } + + const key = current.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + args[key] = next; + i += 1; + } else { + args[key] = "true"; + } + } + return args; +} + +function fail(message) { + process.stderr.write(`[i18n-check] ${message}\n`); + process.exit(1); +} + +// No shell in between: on Windows, cmd.exe eats the `^` of `^{commit}`, and a ref name +// should never be given the chance to be read as shell syntax anyway. +function runGit(args, input) { + return execFileSync("git", ["-c", "core.quotepath=false", ...args], { + encoding: "utf8", + input, + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }).trim(); +} + +function refExists(ref) { + if (!ref) return false; + try { + runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +/** + * Locales come from two places, and we need the union of both. + * + * The VuePress config is the declared source of truth, but a locale directory can exist + * on disk before anyone remembers to declare it (this is exactly what happened to `docs/pt/`). + * An undeclared locale directory that we failed to recognize would be treated as English + * source material, and we would then demand translations *of it* - so we trust the disk too, + * and surface the discrepancy instead of silently papering over it. + */ +function detectLocales(repoRoot) { + const declared = localesFromConfig(path.join(repoRoot, CONFIG_PATH)); + const onDisk = localesFromDisk(path.join(repoRoot, DOCS_DIR)); + + const locales = [...new Set([...declared, ...onDisk])].sort(); + const undeclared = onDisk.filter((locale) => !declared.includes(locale)); + const unbuilt = declared.filter((locale) => !onDisk.includes(locale)); + + return { locales, declared, onDisk, undeclared, unbuilt }; +} + +function localesFromConfig(configPath) { + if (!fs.existsSync(configPath)) { + fail(`VuePress config not found at ${configPath}. Run this from the repository root.`); + } + + // config.js is CommonJS and side-effect free (it only reads process.env), so requiring it + // is both safe and far more robust than pattern-matching the file as text. + let config; + try { + config = createRequire(import.meta.url)(configPath); + } catch (error) { + fail(`Could not load ${CONFIG_PATH}: ${error.message}`); + } + + const keys = [ + ...Object.keys(config.locales || {}), + ...Object.keys((config.themeConfig || {}).locales || {}), + ]; + + return [...new Set(keys.map((key) => key.replaceAll("/", "")).filter(Boolean))].sort(); +} + +function localesFromDisk(docsRoot) { + return fs + .readdirSync(docsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => entry.name) + .filter((name) => LOCALE_DIR_PATTERN.test(name)) + .sort(); +} + +function resolveBaseRef(requestedBase) { + const candidates = [ + requestedBase, + requestedBase?.startsWith("origin/") ? requestedBase.replace(/^origin\//, "") : null, + "origin/master", + "master", + "origin/main", + "main", + ].filter(Boolean); + + for (const candidate of candidates) { + if (refExists(candidate)) { + return { ref: candidate, fellBack: candidate !== requestedBase }; + } + } + + return { ref: null, fellBack: true }; +} + +/** + * `--name-status` rather than `--name-only`: without the status letter we cannot tell a + * deleted source from a modified one, and would demand translations of files that no + * longer exist. `-M` folds renames into a single R entry. + */ +function readChanges(baseRef, headRef) { + let output; + try { + output = runGit(["diff", "--name-status", "-M", `${baseRef}...${headRef}`]); + } catch (error) { + fail( + `git diff ${baseRef}...${headRef} failed: ${error.message.trim()}\n` + + "If this runs in CI, make sure the checkout is not shallow (actions/checkout with fetch-depth: 0)." + ); + } + + return parseNameStatus(output); +} + +function parseNameStatus(output) { + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split("\t"); + const code = parts[0]; + if (code.startsWith("R") || code.startsWith("C")) { + return { status: code[0], oldPath: parts[1], path: parts[2] }; + } + return { status: code[0], oldPath: null, path: parts[1] }; + }); +} + +/** + * Every change since the base branch AS THE WORKING TREE STANDS: committed, staged, merely + * saved, or not even tracked yet (reported as an addition). + * + * A commit range cannot see work in progress, and work in progress is exactly what a person + * at their desk is asking about: `--working-tree` checks it before committing, and + * `--stamp --changed` stamps right after editing. + */ +function readWorkingTreeChanges(baseRef) { + const mergeBase = runGit(["merge-base", baseRef, "HEAD"]); + const tracked = parseNameStatus(runGit(["diff", "--name-status", "-M", mergeBase])); + const untracked = runGit(["ls-files", "--others", "--exclude-standard", "--", DOCS_DIR]) + .split("\n") + .filter(Boolean) + .map((filePath) => ({ status: "A", oldPath: null, path: filePath })); + + return [...tracked, ...untracked]; +} + +/** + * THE HASH. Git's own blob hash of each file, asked from git itself - what + * `git hash-object ` prints, so anyone can verify a stamp by hand. + * + * It has to come from git rather than from hashing the bytes on disk: with `core.autocrlf` + * a Windows checkout holds CRLF where the repository stores LF, and hashing the raw bytes + * gives a different answer on a translator's machine than in CI. Every page stamped on + * Windows would then report as stale on Linux. `hash-object` applies the same line-ending + * rules as `git add`, so the hash is the one git stores, on every platform. + * + * One subprocess for the whole list (`--stdin-paths`), not one per page. + * + * Any change to the source file - one character, one newline - produces a different hash. + * That is the property the whole check rests on. + */ +function gitBlobShas(filePaths) { + if (filePaths.length === 0) return new Map(); + + const hashes = runGit(["hash-object", "--stdin-paths"], `${filePaths.join("\n")}\n`).split("\n"); + return new Map(filePaths.map((filePath, index) => [filePath, hashes[index].trim()])); +} + +/** + * Reads back the source HASH a translation recorded in its front matter. + * + * Returns null when the page has no front matter at all, or has one that never recorded a + * hash. Null is not "up to date" and not "stale" - it means we cannot judge this page by + * content, and callers must say so rather than guess. + */ +function readSourceStamp(absolutePath) { + if (!fs.existsSync(absolutePath)) return null; + + const match = fs.readFileSync(absolutePath, "utf8").match(FRONT_MATTER_RE); + if (!match) return null; + + const line = match[1].split("\n").find((entry) => entry.trim().startsWith(`${STAMP_KEY}:`)); + if (!line) return null; + + return line.slice(line.indexOf(":") + 1).trim().replace(/^["']|["']$/g, "") || null; +} + +/** + * Records a source HASH in a translated page, creating a front-matter block when the page + * has none and leaving any existing keys untouched. Everything outside the stamp line is + * preserved byte for byte - these are hand-written translations, not generated files. + * + * An existing stamp that differs is the drift signal itself, so it is only overwritten when + * `overwrite` says the caller means it - see stampTranslations(). + * + * Returns which of the five things happened, so --stamp can report real numbers instead of + * claiming to have written every file it looked at. + */ +function writeSourceStamp(absolutePath, sha, { overwrite }) { + const content = fs.readFileSync(absolutePath, "utf8"); + const stampLine = `${STAMP_KEY}: ${sha}`; + const match = content.match(FRONT_MATTER_RE); + + if (!match) { + fs.writeFileSync(absolutePath, `---\n${stampLine}\n---\n\n${content}`, "utf8"); + return "created"; + } + + const lines = match[1].split("\n"); + const index = lines.findIndex((entry) => entry.trim().startsWith(`${STAMP_KEY}:`)); + + if (index === -1) { + lines.push(stampLine); + } else if (lines[index].trim() === stampLine) { + return "unchanged"; + } else if (!overwrite) { + return "kept"; + } else { + lines[index] = stampLine; + } + + // Function replacer: a `$` inside existing front matter must not be read as a pattern. + const rebuilt = `---\n${lines.join("\n")}\n---\n`; + fs.writeFileSync(absolutePath, content.replace(FRONT_MATTER_RE, () => rebuilt), "utf8"); + return index === -1 ? "stamped" : "updated"; +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function isSourceDoc(filePath, locales) { + if (!filePath.startsWith(`${DOCS_DIR}/`)) return false; + if (!filePath.endsWith(".md")) return false; + if (filePath.startsWith(`${DOCS_DIR}/.vuepress/`)) return false; + return !locales.some((locale) => filePath.startsWith(`${DOCS_DIR}/${locale}/`)); +} + +function localeOf(filePath, locales) { + return locales.find((locale) => filePath.startsWith(`${DOCS_DIR}/${locale}/`)) || null; +} + +function toLocalizedPath(sourceFile, locale) { + return `${DOCS_DIR}/${locale}/${sourceFile.slice(DOCS_DIR.length + 1)}`; +} + +function toSourcePath(localizedFile, locale) { + return `${DOCS_DIR}/${localizedFile.slice(`${DOCS_DIR}/${locale}/`.length)}`; +} + +/** + * Walks by hand rather than using `readdirSync({ recursive: true })` + `dirent.parentPath`, + * which would silently produce wrong paths on Node older than 20.12. + */ +function listMarkdown(dir, repoRoot) { + const absolute = path.join(repoRoot, dir); + if (!fs.existsSync(absolute)) return []; + + const found = []; + const walk = (relativeDir) => { + for (const entry of fs.readdirSync(path.join(repoRoot, relativeDir), { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const relativePath = `${relativeDir}/${entry.name}`; + if (entry.isDirectory()) { + walk(relativePath); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + found.push(normalizePath(relativePath)); + } + } + }; + + walk(dir); + return found; +} + +function loadBaseline(repoRoot) { + const empty = { entries: [], ignoredLocales: {}, index: new Set() }; + const baselinePath = path.join(repoRoot, BASELINE_PATH); + if (!fs.existsSync(baselinePath)) return empty; + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(baselinePath, "utf8")); + } catch (error) { + fail(`Could not parse ${BASELINE_PATH}: ${error.message}`); + } + + const entries = parsed.entries || []; + return { + entries, + ignoredLocales: parsed.ignoredLocales || {}, + index: new Set(entries.map((entry) => `${entry.status}|${entry.path}`)), + }; +} + +function isKnownDebt(finding, baseline) { + return baseline.index.has(`${finding.status}|${finding.path}`); +} + +/** + * Regenerating the baseline must not quietly discard `ignoredLocales`: that list is a team + * decision about which languages are in scope, not something derived from the file tree. + */ +function writeBaseline(repoRoot, findings, ignoredLocales, generatedAt) { + const entries = findings + .filter((finding) => ALL_DRIFT_STATUSES.includes(finding.status)) + .map((finding) => ({ + status: finding.status, + path: finding.path, + source: finding.source, + })) + .sort((a, b) => `${a.status}${a.path}`.localeCompare(`${b.status}${b.path}`)); + + const payload = { + description: + "Known translation gaps accepted at the time this file was generated. Findings listed " + + "here are still reported, but do not fail CI, so only new regressions block a build. " + + "Regenerate with: npm run i18n:baseline", + generatedAt, + ignoredLocalesHelp: + "Locales listed below are out of scope: their directories are still recognized as " + + "translations (so their pages are never mistaken for English sources), but they are " + + "left out of every report. Remove one to start tracking it.", + ignoredLocales, + entries, + }; + + fs.writeFileSync( + path.join(repoRoot, BASELINE_PATH), + `${JSON.stringify(payload, null, 2)}\n`, + "utf8" + ); + + return entries.length; +} + +/** + * Diff mode: what did this change set do to the source docs, and did the translations follow? + */ +function collectDiffFindings({ changes, allLocales, reportedLocales, repoRoot }) { + const changedPaths = new Set(changes.map((change) => normalizePath(change.path))); + const sourceShas = gitBlobShas( + changes + .filter((change) => change.status !== "D") + .map((change) => normalizePath(change.path)) + .filter((filePath) => isSourceDoc(filePath, allLocales)) + .filter((filePath) => fs.existsSync(path.join(repoRoot, filePath))) + ); + const findings = []; + + for (const change of changes) { + const filePath = normalizePath(change.path); + // `allLocales` here, not `reportedLocales`: an out-of-scope locale directory must still + // be recognized as a translation, or its pages get mistaken for English sources. + if (!isSourceDoc(filePath, allLocales)) continue; + + for (const locale of reportedLocales) { + const localizedPath = toLocalizedPath(filePath, locale); + const exists = fs.existsSync(path.join(repoRoot, localizedPath)); + + // A deleted source does not need translating - its translation needs deleting. + if (change.status === "D") { + if (exists) { + findings.push({ status: "orphaned", locale, path: localizedPath, source: filePath }); + } + continue; + } + + if (!exists) { + findings.push({ status: "missing", locale, path: localizedPath, source: filePath }); + continue; + } + + findings.push({ + locale, + path: localizedPath, + source: filePath, + ...judgeTranslation({ + repoRoot, + currentSha: sourceShas.get(filePath), + localizedPath, + touchedInRange: changedPaths.has(localizedPath), + }), + }); + } + } + + // A rename on the source side leaves the translation sitting at the old path. + for (const change of changes) { + if (change.status !== "R" || !change.oldPath) continue; + const oldPath = normalizePath(change.oldPath); + if (!isSourceDoc(oldPath, allLocales)) continue; + + for (const locale of reportedLocales) { + const staleLocalizedPath = toLocalizedPath(oldPath, locale); + if (fs.existsSync(path.join(repoRoot, staleLocalizedPath))) { + findings.push({ + status: "orphaned", + locale, + path: staleLocalizedPath, + source: `${oldPath} (renamed to ${normalizePath(change.path)})`, + }); + } + } + } + + return findings; +} + +/** + * Is this translation the one that matches the current English page? + * + * With a stamp the answer is certain: the recorded hash either matches the source or it does + * not, and no amount of unrelated editing inside the translated file can fake a match. + * Without one we fall back to the older, weaker question - did the file appear in the same + * change? - and flag the finding so the report can say how much it is worth. + */ +function judgeTranslation({ repoRoot, currentSha, localizedPath, touchedInRange }) { + const stamp = readSourceStamp(path.join(repoRoot, localizedPath)); + + if (!stamp) { + return { + status: touchedInRange ? "updated" : "needs_review", + unstamped: true, + detail: `no \`${STAMP_KEY}\` - verdict inferred from the diff alone`, + }; + } + + // The comparison that makes the verdict trustworthy: the hash the translation recorded, + // against the hash of the English file as it stands now. + if (stamp === currentSha) return { status: "updated" }; + + // Same status as audit mode gives the same condition: the baseline is written from an + // audit and matched on status, so a second name here would make known debt look new. + return { + status: "stale", + detail: `translates source ${stamp.slice(0, 7)}, current source is ${currentSha.slice(0, 7)}`, + }; +} + +/** + * Audit mode: the standing state of the repository, independent of any diff. + */ +function collectAuditFindings({ allLocales, reportedLocales, repoRoot }) { + const findings = []; + + const sourceFiles = listMarkdown(DOCS_DIR, repoRoot).filter((filePath) => + isSourceDoc(filePath, allLocales) + ); + const sourceShas = gitBlobShas(sourceFiles); + + for (const sourceFile of sourceFiles) { + for (const locale of reportedLocales) { + const localizedPath = toLocalizedPath(sourceFile, locale); + const localizedAbs = path.join(repoRoot, localizedPath); + + if (!fs.existsSync(localizedAbs)) { + findings.push({ status: "missing", locale, path: localizedPath, source: sourceFile }); + continue; + } + + // Hash comparison, same rule as diff mode: what the translation claims to translate, + // against what the English page actually is right now. + const stamp = readSourceStamp(localizedAbs); + if (!stamp) { + findings.push({ + status: "unstamped", + locale, + path: localizedPath, + source: sourceFile, + unstamped: true, + }); + continue; + } + + const currentSha = sourceShas.get(sourceFile); + // Hashes differ => the English page moved on after this translation was made. + if (stamp !== currentSha) { + findings.push({ + status: "stale", + locale, + path: localizedPath, + source: sourceFile, + detail: `translates source ${stamp.slice(0, 7)}, current source is ${currentSha.slice(0, 7)}`, + }); + } + } + } + + for (const locale of reportedLocales) { + for (const localizedPath of listMarkdown(`${DOCS_DIR}/${locale}`, repoRoot)) { + if (localeOf(localizedPath, allLocales) !== locale) continue; + const sourcePath = toSourcePath(localizedPath, locale); + if (!fs.existsSync(path.join(repoRoot, sourcePath))) { + findings.push({ status: "orphaned", locale, path: localizedPath, source: sourcePath }); + } + } + } + + return { findings, sourceFiles }; +} + +/** + * How far along each locale is, overall and per top-level section of `docs/`. + * + * An audit only records what is wrong, so coverage is what remains: every source page that + * drew no `missing` finding is translated, and every one that drew no finding at all is up + * to date. "Translated" and "up to date" are kept apart on purpose - an unstamped page + * exists, but nothing proves it still matches its source. + */ +function buildCoverage({ findings, sourceFiles, locales }) { + const sectionOf = (sourceFile) => { + const parts = sourceFile.split("/"); + return parts.length > 2 ? parts[1] : "(root)"; + }; + + return locales.map((locale) => { + const sections = new Map(); + const sectionFor = (sourceFile) => { + const name = sectionOf(sourceFile); + if (!sections.has(name)) { + sections.set(name, { section: name, total: 0, translated: 0, upToDate: 0 }); + } + return sections.get(name); + }; + + for (const sourceFile of sourceFiles) { + const section = sectionFor(sourceFile); + section.total += 1; + section.translated += 1; + section.upToDate += 1; + } + + for (const finding of findings) { + if (finding.locale !== locale || finding.status === "orphaned") continue; + const section = sectionFor(finding.source); + section.upToDate -= 1; + if (finding.status === "missing") section.translated -= 1; + } + + const rows = [...sections.values()].sort((a, b) => a.section.localeCompare(b.section)); + const sum = (key) => rows.reduce((total, row) => total + row[key], 0); + + return { + locale, + total: sum("total"), + translated: sum("translated"), + upToDate: sum("upToDate"), + sections: rows, + }; + }); +} + +function buildReport({ mode, findings, coverage, locales, localeInfo, baseline, range, generatedAt }) { + const ignoredLocales = Object.keys(baseline.ignoredLocales); + const annotated = findings.map((finding) => ({ + ...finding, + known: isKnownDebt(finding, baseline), + })); + + const counts = { missing: 0, orphaned: 0, stale: 0, needs_review: 0, updated: 0, unstamped: 0 }; + const newCounts = { missing: 0, orphaned: 0, stale: 0, needs_review: 0, updated: 0, unstamped: 0 }; + + for (const finding of annotated) { + counts[finding.status] += 1; + if (!finding.known) newCounts[finding.status] += 1; + } + + return { + generatedAt, + mode, + range, + locales, + ignoredLocales, + localeWarnings: { + // Only worth flagging for locales we actually report on; an out-of-scope directory + // is not expected to be wired into VuePress. + undeclaredInConfig: localeInfo.undeclared.filter((l) => !ignoredLocales.includes(l)), + declaredButAbsentOnDisk: localeInfo.unbuilt, + }, + counts, + newCounts, + unstamped: annotated.filter((finding) => finding.unstamped).length, + baselineSize: baseline.entries.length, + // Audit mode only: a diff sees a handful of pages, not the state of a locale. + coverage: coverage || [], + findings: annotated, + // Kept for backwards compatibility with the workflow's issue-creation step. + hasDrift: ALL_DRIFT_STATUSES.some((status) => newCounts[status] > 0), + }; +} + +function buildMarkdownReport(report) { + const lines = ["## Translation Drift Report", ""]; + + for (const locale of report.localeWarnings.undeclaredInConfig) { + lines.push( + `> ๐Ÿ”ง \`docs/${locale}/\` exists on disk but is not declared in \`${CONFIG_PATH}\`. ` + + "It is treated as a locale here, but VuePress will not build a language switcher for it." + ); + lines.push(""); + } + + if (report.mode === "diff" && report.findings.length === 0) { + lines.push("No source documentation changed in this range. Nothing to translate."); + return lines.join("\n"); + } + + if (report.mode === "audit" && report.findings.length === 0) { + lines.push("Every source page has an up-to-date translation in every locale. โœ…"); + return lines.join("\n"); + } + + lines.push( + `Locales: ${report.locales.map((locale) => `\`${locale}\``).join(", ") || "_none_"}`, + report.mode === "diff" ? `Range: \`${report.range}\`` : "Mode: full repository audit", + "" + ); + + lines.push("| Status | New | Known debt | Meaning |", "| --- | ---: | ---: | --- |"); + for (const status of [...ALL_DRIFT_STATUSES, "updated", "unstamped"]) { + if (report.counts[status] === 0) continue; + const known = report.counts[status] - report.newCounts[status]; + lines.push( + `| ${STATUS_META[status].emoji} ${status} | ${report.newCounts[status]} | ${known} | ${STATUS_META[status].label} |` + ); + } + lines.push(""); + + lines.push(...buildCoverageLines(report.coverage)); + + const actionable = report.findings.filter( + (finding) => !finding.known && ALL_DRIFT_STATUSES.includes(finding.status) + ); + + if (actionable.some((finding) => finding.status === "stale")) { + lines.push( + "To see what changed in the English source of a stale page since it was translated: " + + "`npm run i18n:explain -- `", + "" + ); + } + + if (actionable.length === 0) { + lines.push("No new drift. Everything flagged is already in the baseline.", ""); + } else { + lines.push(`### Needs attention (${actionable.length})`, ""); + for (const finding of groupByLocale(actionable, report.locales)) { + lines.push(`**\`${finding.locale}\`**`, ""); + for (const item of finding.items) { + const detail = item.detail ? ` โ€” ${item.detail}` : ""; + lines.push(`- ${STATUS_META[item.status].emoji} \`${item.path}\` (${item.status})${detail}`); + } + lines.push(""); + } + } + + if (report.baselineSize > 0 || report.ignoredLocales.length > 0) { + const parts = []; + if (report.baselineSize > 0) { + parts.push(`${report.baselineSize} known gap(s), reported but not blocking`); + } + if (report.ignoredLocales.length > 0) { + parts.push( + `locale(s) ${report.ignoredLocales.map((l) => `\`${l}\``).join(", ")} out of scope` + ); + } + lines.push(`Baseline (\`${BASELINE_PATH}\`): ${parts.join("; ")}.`); + } + + const footnote = + report.unstamped > 0 + ? `> ${report.unstamped} translation(s) carry no \`${STAMP_KEY}\`, so their verdict is inferred ` + + "from the diff alone and may be wrong. Run `npm run i18n:stamp` to make it exact." + : `> Verdicts are exact: each translation records the \`${STAMP_KEY}\` it was made from, so ` + + "editing a translated file cannot make it look current when it is not."; + + lines.push("", footnote); + + return lines.join("\n"); +} + +function buildCoverageLines(coverage) { + const lines = []; + const share = (count, total) => + `${count} (${total === 0 ? 100 : Math.round((count / total) * 100)}%)`; + + for (const entry of coverage) { + lines.push( + `### Coverage - \`${entry.locale}\``, + "", + "| Section | Source pages | Translated | Up to date |", + "| --- | ---: | ---: | ---: |" + ); + for (const row of entry.sections) { + lines.push( + `| \`${row.section}\` | ${row.total} | ${share(row.translated, row.total)} | ${share(row.upToDate, row.total)} |` + ); + } + lines.push( + `| **all** | ${entry.total} | ${share(entry.translated, entry.total)} | ${share(entry.upToDate, entry.total)} |`, + "" + ); + } + + return lines; +} + +function groupByLocale(findings, locales) { + return locales + .map((locale) => ({ locale, items: findings.filter((finding) => finding.locale === locale) })) + .filter((group) => group.items.length > 0); +} + +function emitGithubAnnotations(report) { + if (process.env.GITHUB_ACTIONS !== "true") return; + + for (const finding of report.findings) { + if (finding.known || !ALL_DRIFT_STATUSES.includes(finding.status)) continue; + const message = `${finding.status}: ${finding.path} (source: ${finding.source})`; + process.stdout.write(`::warning file=${finding.source},title=i18n drift::${message}\n`); + } +} + +/** + * Writes the source HASH into every translated page: for each English page, hash it once + * and stamp that value into each of its translations. This is the only mode that modifies + * documentation files, so it is always explicit and never runs in CI. + * + * Translators run this after carrying a change across. Skipping it leaves the page reporting + * as behind, which is the deliberate failure direction - a stale warning is recoverable, a + * false "up to date" is not. + * + * A bare `--stamp` only fills in pages that have no stamp yet. It must not refresh a stamp + * that is out of date: a translator who carried one page across and then stamped everything + * would silently mark every other stale page as current. Refreshing takes naming the page + * (`--stamp `), pointing at the pages just worked on (`--stamp --changed`), or + * `--force` to say "all of them, I mean it". + * + * `only` is the set of translated pages to restrict to, or null for all of them. + * + * Bootstrapping is an assertion, not a measurement: stamping everything at once declares + * "these translations are current as of today", which nobody can actually verify. That is + * the same bargain as the baseline - freeze the past, watch what comes next. + */ +function stampTranslations({ allLocales, reportedLocales, repoRoot, only, force }) { + const tally = { created: 0, stamped: 0, updated: 0, unchanged: 0, kept: 0, skipped: 0 }; + const overwrite = force || Boolean(only); + + const sourceFiles = listMarkdown(DOCS_DIR, repoRoot).filter((filePath) => + isSourceDoc(filePath, allLocales) + ); + + const sourceShas = gitBlobShas(sourceFiles); + + for (const sourceFile of sourceFiles) { + const sourceSha = sourceShas.get(sourceFile); + + for (const locale of reportedLocales) { + const localizedPath = toLocalizedPath(sourceFile, locale); + if (only && !only.has(localizedPath)) continue; + + const localizedAbs = path.join(repoRoot, localizedPath); + if (!fs.existsSync(localizedAbs)) { + tally.skipped += 1; + continue; + } + + tally[writeSourceStamp(localizedAbs, sourceSha, { overwrite })] += 1; + } + } + + return tally; +} + +/** + * Shows a translator WHAT to carry across: the diff of the English page between the version + * the translation was made from and the version that exists now. + * + * Costs nothing extra to support - the stamp is a git blob hash, so the old English text is + * still in the repository under that very name, and git can diff it against today's file. + */ +function explainTranslation({ target, allLocales, repoRoot }) { + const localizedPath = normalizePath(target); + const locale = localeOf(localizedPath, allLocales); + if (!locale) { + fail(`${target} is not a translated page. Expected a path like ${DOCS_DIR}//page.md.`); + } + if (!fs.existsSync(path.join(repoRoot, localizedPath))) { + fail(`${localizedPath} does not exist.`); + } + + const sourcePath = toSourcePath(localizedPath, locale); + if (!fs.existsSync(path.join(repoRoot, sourcePath))) { + fail(`The source of ${localizedPath} (${sourcePath}) no longer exists: the translation is orphaned.`); + } + + const stamp = readSourceStamp(path.join(repoRoot, localizedPath)); + if (!stamp) { + fail( + `${localizedPath} carries no \`${STAMP_KEY}\`, so there is no record of which version of ` + + `${sourcePath} it translates.` + ); + } + + const currentSha = gitBlobShas([sourcePath]).get(sourcePath); + if (stamp === currentSha) { + return `${localizedPath} is up to date with ${sourcePath} (${currentSha.slice(0, 7)}).\n`; + } + + try { + runGit(["cat-file", "-e", `${stamp}^{blob}`]); + } catch { + fail( + `${localizedPath} records source version ${stamp.slice(0, 7)}, which was not found in this ` + + "repository. The stamp may have been typed by hand, or the clone may be shallow." + ); + } + + return ( + `${sourcePath} changed since ${localizedPath} was translated ` + + `(${stamp.slice(0, 7)} -> ${currentSha.slice(0, 7)}).\n` + + `Carry these changes across, then run: npm run i18n:stamp -- ${localizedPath}\n\n` + + `${runGit(["diff", "--no-color", stamp, sourcePath])}\n` + ); +} + +function resolveBaseRefOrFail(requestedBase) { + const { ref: baseRef, fellBack } = resolveBaseRef(requestedBase); + + if (!baseRef) { + fail( + `Base ref "${requestedBase}" could not be resolved, and no fallback branch exists. ` + + "Refusing to report a clean run against an unknown range." + ); + } + + if (fellBack) { + process.stderr.write( + `[i18n-check] Base ref "${requestedBase}" not found. Falling back to "${baseRef}".\n` + ); + } + + return baseRef; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const repoRoot = process.cwd(); + const generatedAt = new Date().toISOString(); + + if (args.help === "true") { + process.stdout.write(usage()); + return; + } + + const mode = args.audit === "true" ? "audit" : "diff"; + const localeInfo = detectLocales(repoRoot); + + if (localeInfo.locales.length === 0) { + fail(`No locales found in ${CONFIG_PATH} or under ${DOCS_DIR}/.`); + } + + // Loaded before collection: `ignoredLocales` decides which locales are reported at all. + const baseline = loadBaseline(repoRoot); + const allLocales = localeInfo.locales; + const reportedLocales = allLocales.filter((locale) => !baseline.ignoredLocales[locale]); + + if (reportedLocales.length === 0) { + fail( + `Every detected locale (${allLocales.join(", ")}) is listed in ignoredLocales. ` + + `Nothing left to check - edit ${BASELINE_PATH}.` + ); + } + + for (const locale of localeInfo.undeclared) { + if (baseline.ignoredLocales[locale]) continue; + process.stderr.write( + `[i18n-check] Warning: docs/${locale}/ exists but is not declared in ${CONFIG_PATH}.\n` + ); + } + + if (args.explain) { + if (args.explain === "true") fail("--explain needs the path of a translated page."); + process.stdout.write(explainTranslation({ target: args.explain, allLocales, repoRoot })); + return; + } + + if (args.stamp) { + let only = args.stamp === "true" ? null : new Set([normalizePath(args.stamp)]); + if (args.changed === "true") { + const baseRef = resolveBaseRefOrFail(args.base || "origin/master"); + only = new Set( + readWorkingTreeChanges(baseRef) + .filter((change) => change.status !== "D") + .map((change) => normalizePath(change.path)) + ); + } + const tally = stampTranslations({ + allLocales, + reportedLocales, + repoRoot, + only, + force: args.force === "true", + }); + process.stdout.write( + `[i18n-check] Stamped translations with ${STAMP_KEY}: ` + + `${tally.created} front matter created, ${tally.stamped} key added, ` + + `${tally.updated} updated, ${tally.unchanged} already current, ` + + `${tally.kept} out of date left untouched.\n` + ); + if (tally.kept > 0) { + process.stdout.write( + "[i18n-check] Out-of-date stamps are kept so the drift stays visible. Once a page is " + + "translated, refresh it with --stamp or --stamp --changed " + + "(or everything with --stamp --force).\n" + ); + } + return; + } + + let findings = []; + let coverage = null; + let range = null; + + if (mode === "diff") { + const requestedBase = args.base || "origin/master"; + const workingTree = args["working-tree"] === "true"; + if (workingTree && args.head) { + fail("--working-tree already ends at the files on disk; it cannot be combined with --head."); + } + + const headRef = args.head || "HEAD"; + const baseRef = resolveBaseRefOrFail(requestedBase); + + range = workingTree ? `${baseRef}...working tree` : `${baseRef}...${headRef}`; + findings = collectDiffFindings({ + changes: workingTree ? readWorkingTreeChanges(baseRef) : readChanges(baseRef, headRef), + allLocales, + reportedLocales, + repoRoot, + }); + } else { + const audit = collectAuditFindings({ allLocales, reportedLocales, repoRoot }); + findings = audit.findings; + coverage = buildCoverage({ ...audit, locales: reportedLocales }); + } + + if (args["update-baseline"] === "true") { + if (mode !== "audit") { + fail("--update-baseline requires --audit, so the baseline covers the whole repository."); + } + const frozen = writeBaseline(repoRoot, findings, baseline.ignoredLocales, generatedAt); + const ignored = Object.keys(baseline.ignoredLocales); + const suffix = ignored.length > 0 ? `, ignored locales kept: ${ignored.join(", ")}` : ""; + process.stdout.write( + `[i18n-check] Baseline written to ${BASELINE_PATH} (${frozen} entries${suffix}).\n` + ); + return; + } + + const report = buildReport({ + mode, + findings, + coverage, + locales: reportedLocales, + localeInfo, + baseline, + range, + generatedAt, + }); + const markdown = buildMarkdownReport(report); + + if (args["json-out"]) { + fs.writeFileSync(args["json-out"], `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + if (args["markdown-out"]) { + fs.writeFileSync(args["markdown-out"], `${markdown}\n`, "utf8"); + } + + process.stdout.write(`${markdown}\n`); + emitGithubAnnotations(report); + + const failOn = resolveFailOn(args); + const blocking = failOn.filter((status) => report.newCounts[status] > 0); + if (blocking.length > 0) { + process.stderr.write( + `[i18n-check] New drift outside the baseline: ${blocking + .map((status) => `${status}=${report.newCounts[status]}`) + .join(", ")}\n` + ); + process.exit(2); + } +} + +function resolveFailOn(args) { + // `--fail-on-missing true` is kept as an alias for the original flag. + if (args["fail-on-missing"] === "true" && !args["fail-on"]) return ["missing"]; + if (!args["fail-on"] || args["fail-on"] === "none") return []; + + const requested = args["fail-on"].split(",").map((value) => value.trim()).filter(Boolean); + const unknown = requested.filter((status) => !ALL_DRIFT_STATUSES.includes(status)); + if (unknown.length > 0) { + fail(`Unknown --fail-on status: ${unknown.join(", ")}. Valid: ${ALL_DRIFT_STATUSES.join(", ")}`); + } + return requested; +} + +function usage() { + return [ + "Usage: node scripts/i18n-drift-check.mjs [options]", + "", + " --base Base ref for diff mode (default: origin/master)", + " --head Head ref for diff mode (default: HEAD)", + " --working-tree Diff mode: end at the files on disk instead of --head, so that", + " uncommitted and untracked changes are reported too", + " --audit Scan the whole repository instead of a git range", + " --json-out Write the JSON report", + " --markdown-out Write the Markdown report", + " --fail-on Exit 2 on new findings, e.g. missing,orphaned", + " --update-baseline Rewrite the baseline from a full audit (implies --audit)", + " --stamp [path] Record the current source hash in translations that have none,", + " or refresh it in the one translation given", + " --changed With --stamp: refresh the translations edited since --base,", + " committed or not", + " --explain Show what changed in the source since was translated", + " --force With --stamp: also refresh stamps that are out of date", + " --help Show this message", + "", + ].join("\n"); +} + +main(); diff --git a/scripts/i18n-drift-check.test.mjs b/scripts/i18n-drift-check.test.mjs new file mode 100644 index 000000000..a58ccd317 --- /dev/null +++ b/scripts/i18n-drift-check.test.mjs @@ -0,0 +1,370 @@ +/***** + License + -------------- + Copyright ยฉ 2026 Mojaloop Foundation + + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 + (the "License") and you may not use these files except in compliance with the [License](http://www.apache.org/licenses/LICENSE-2.0). + + You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the [License](http://www.apache.org/licenses/LICENSE-2.0). + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + + * Chancel WOROU + * Nounagnon02 + + -------------- + ******/ + +/** + * Tests for i18n-drift-check.mjs. Run with: npm run i18n:test + * + * The script is exercised as a CLI against a throwaway git repository, because what matters + * is what a translator or CI actually gets back: the stamps written, the report, the exit code. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync, spawnSync } from "node:child_process"; + +const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "i18n-drift-check.mjs"); +const OLD_SHA = "1111111111111111111111111111111111111111"; + +function createRepo(t, { autocrlf = "false" } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "i18n-drift-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const repo = { + root, + git: (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(), + write(relativePath, content) { + const absolute = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, content, "utf8"); + }, + read: (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"), + commit(message) { + repo.git("add", "-A"); + repo.git("commit", "--quiet", "-m", message); + return repo.git("rev-parse", "HEAD"); + }, + run(...args) { + const result = spawnSync(process.execPath, [SCRIPT, ...args], { cwd: root, encoding: "utf8" }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + }, + report(...args) { + const result = repo.run(...args, "--json-out", "report.json"); + assert.ok(fs.existsSync(path.join(root, "report.json")), `no report written: ${result.stderr}`); + return { ...result, report: JSON.parse(repo.read("report.json")) }; + }, + }; + + repo.git("init", "--quiet"); + repo.git("config", "user.email", "test@example.com"); + repo.git("config", "user.name", "Test"); + repo.git("config", "core.autocrlf", autocrlf); + repo.git("config", "core.safecrlf", "false"); + repo.write("docs/.vuepress/config.js", 'module.exports = { locales: { "/": {}, "/fr/": {} } };\n'); + // The baseline is written under scripts/, which always exists in the real repository. + fs.mkdirSync(path.join(root, "scripts")); + + return repo; +} + +function stamped(sha, body) { + return `---\ni18n_source_sha: ${sha}\n---\n\n${body}`; +} + +function stampOf(repo, relativePath) { + const match = repo.read(relativePath).match(/^i18n_source_sha: (.+)$/m); + return match ? match[1].trim() : null; +} + +test("--stamp records the hash git stores, even when the working copy uses CRLF", (t) => { + const repo = createRepo(t, { autocrlf: "true" }); + repo.write("docs/guide.md", "# Guide\r\n\r\nSome text.\r\n"); + repo.write("docs/fr/guide.md", "# Guide\n\nDu texte.\n"); + repo.commit("initial"); + + repo.run("--stamp"); + + assert.equal(stampOf(repo, "docs/fr/guide.md"), repo.git("rev-parse", "HEAD:docs/guide.md")); +}); + +test("--audit treats a page stamped with git's hash as current when the working copy uses CRLF", (t) => { + const repo = createRepo(t, { autocrlf: "true" }); + repo.write("docs/guide.md", "# Guide\r\n\r\nSome text.\r\n"); + repo.write("docs/fr/guide.md", "placeholder\n"); + repo.commit("source"); + repo.write("docs/fr/guide.md", stamped(repo.git("rev-parse", "HEAD:docs/guide.md"), "# Guide\n")); + repo.commit("translation"); + + const { report } = repo.report("--audit"); + + assert.deepEqual(report.findings, []); +}); + +test("a stale page frozen in the baseline is known debt in diff mode too", (t) => { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n\nVersion one.\n"); + repo.write("docs/fr/guide.md", stamped(OLD_SHA, "# Guide\n")); + const base = repo.commit("initial"); + + repo.run("--audit", "--update-baseline"); + repo.write("docs/guide.md", "# Guide\n\nVersion two.\n"); + repo.commit("source moves on"); + + const { code, report } = repo.report("--base", base, "--head", "HEAD", "--fail-on", "stale,needs_review"); + + const finding = report.findings.find((entry) => entry.path === "docs/fr/guide.md"); + assert.equal(finding.status, "stale"); + assert.equal(finding.known, true); + assert.equal(code, 0); +}); + +test("bulk --stamp leaves a page whose stamp is out of date untouched", (t) => { + const repo = createRepo(t); + repo.write("docs/behind.md", "# Behind\n"); + repo.write("docs/fr/behind.md", stamped(OLD_SHA, "# En retard\n")); + repo.commit("initial"); + + const { stdout } = repo.run("--stamp"); + + assert.equal(stampOf(repo, "docs/fr/behind.md"), OLD_SHA); + assert.match(stdout, /1 out of date left untouched/); +}); + +test("bulk --stamp still stamps pages that have no stamp yet", (t) => { + const repo = createRepo(t); + repo.write("docs/fresh.md", "# Fresh\n"); + repo.write("docs/fr/fresh.md", "# Nouveau\n"); + repo.commit("initial"); + + repo.run("--stamp"); + + assert.equal(stampOf(repo, "docs/fr/fresh.md"), repo.git("rev-parse", "HEAD:docs/fresh.md")); +}); + +test("--stamp refreshes an out-of-date stamp on that page only", (t) => { + const repo = createRepo(t); + repo.write("docs/one.md", "# One\n"); + repo.write("docs/two.md", "# Two\n"); + repo.write("docs/fr/one.md", stamped(OLD_SHA, "# Un\n")); + repo.write("docs/fr/two.md", stamped(OLD_SHA, "# Deux\n")); + repo.commit("initial"); + + repo.run("--stamp", "docs/fr/one.md"); + + assert.equal(stampOf(repo, "docs/fr/one.md"), repo.git("rev-parse", "HEAD:docs/one.md")); + assert.equal(stampOf(repo, "docs/fr/two.md"), OLD_SHA); +}); + +test("--stamp --force refreshes out-of-date stamps everywhere", (t) => { + const repo = createRepo(t); + repo.write("docs/behind.md", "# Behind\n"); + repo.write("docs/fr/behind.md", stamped(OLD_SHA, "# En retard\n")); + repo.commit("initial"); + + repo.run("--stamp", "--force"); + + assert.equal(stampOf(repo, "docs/fr/behind.md"), repo.git("rev-parse", "HEAD:docs/behind.md")); +}); + +test("--explain shows the source lines that changed since the page was translated", (t) => { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n\nVersion one.\n"); + repo.write("docs/fr/guide.md", "placeholder\n"); + repo.commit("source"); + repo.write("docs/fr/guide.md", stamped(repo.git("rev-parse", "HEAD:docs/guide.md"), "# Guide\n")); + repo.write("docs/guide.md", "# Guide\n\nVersion two.\n"); + repo.commit("source moves on"); + + const { code, stdout } = repo.run("--explain", "docs/fr/guide.md"); + + assert.match(stdout, /^-Version one\.$/m); + assert.match(stdout, /^\+Version two\.$/m); + assert.equal(code, 0); +}); + +test("--explain says so when the translation is already current", (t) => { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n"); + repo.write("docs/fr/guide.md", "placeholder\n"); + repo.commit("source"); + repo.write("docs/fr/guide.md", stamped(repo.git("rev-parse", "HEAD:docs/guide.md"), "# Guide\n")); + + const { code, stdout } = repo.run("--explain", "docs/fr/guide.md"); + + assert.match(stdout, /up to date/); + assert.equal(code, 0); +}); + +test("--explain fails clearly when the recorded source version is unknown to git", (t) => { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n"); + repo.write("docs/fr/guide.md", stamped(OLD_SHA, "# Guide\n")); + repo.commit("initial"); + + const { code, stderr } = repo.run("--explain", "docs/fr/guide.md"); + + assert.match(stderr, /1111111.*not found in this repository/); + assert.equal(code, 1); +}); + +test("--explain fails clearly on a page that carries no stamp", (t) => { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n"); + repo.write("docs/fr/guide.md", "# Guide\n"); + repo.commit("initial"); + + const { code, stderr } = repo.run("--explain", "docs/fr/guide.md"); + + assert.match(stderr, /no `i18n_source_sha`/); + assert.equal(code, 1); +}); + +function coverageRepo(t) { + const repo = createRepo(t); + repo.write("docs/a/one.md", "# One\n"); + repo.write("docs/a/two.md", "# Two\n"); + repo.write("docs/b/three.md", "# Three\n"); + repo.write("docs/fr/b/three.md", "# Trois\n"); + repo.write("docs/fr/a/one.md", "placeholder\n"); + repo.commit("sources"); + repo.write("docs/fr/a/one.md", stamped(repo.git("rev-parse", "HEAD:docs/a/one.md"), "# Un\n")); + repo.commit("translation"); + return repo; +} + +test("--audit reports translation coverage per locale and per section", (t) => { + const { report } = coverageRepo(t).report("--audit"); + + const fr = report.coverage.find((entry) => entry.locale === "fr"); + assert.deepEqual( + { total: fr.total, translated: fr.translated, upToDate: fr.upToDate }, + { total: 3, translated: 2, upToDate: 1 } + ); + assert.deepEqual(fr.sections, [ + { section: "a", total: 2, translated: 1, upToDate: 1 }, + { section: "b", total: 1, translated: 1, upToDate: 0 }, + ]); +}); + +test("--audit prints the coverage table in the Markdown report", (t) => { + const { stdout } = coverageRepo(t).run("--audit"); + + assert.match(stdout, /### Coverage/); + assert.match(stdout, /\| `a` \| 2 \| 1 \(50%\) \| 1 \(50%\) \|/); + assert.match(stdout, /\| \*\*all\*\* \| 3 \| 2 \(67%\) \| 1 \(33%\) \|/); +}); + +test("--stamp --changed refreshes only the translations edited since the base", (t) => { + const repo = createRepo(t); + repo.write("docs/one.md", "# One\n"); + repo.write("docs/two.md", "# Two\n"); + repo.write("docs/fr/one.md", stamped(OLD_SHA, "# Un\n")); + repo.write("docs/fr/two.md", stamped(OLD_SHA, "# Deux\n")); + const base = repo.commit("initial"); + + // Not committed yet: a translator stamps right after editing, before committing. + repo.write("docs/fr/one.md", stamped(OLD_SHA, "# Un, retraduit\n")); + repo.run("--stamp", "--changed", "--base", base); + + assert.equal(stampOf(repo, "docs/fr/one.md"), repo.git("rev-parse", "HEAD:docs/one.md")); + assert.equal(stampOf(repo, "docs/fr/two.md"), OLD_SHA); +}); + +test("--stamp --changed also stamps a brand new translation that git does not track yet", (t) => { + const repo = createRepo(t); + repo.write("docs/one.md", "# One\n"); + repo.write("docs/two.md", "# Two\n"); + repo.write("docs/fr/two.md", "# Deux\n"); + const base = repo.commit("initial"); + + repo.write("docs/fr/one.md", "# Un\n"); + repo.run("--stamp", "--changed", "--base", base); + + assert.equal(stampOf(repo, "docs/fr/one.md"), repo.git("rev-parse", "HEAD:docs/one.md")); + assert.equal(stampOf(repo, "docs/fr/two.md"), null); +}); + +function stampedRepo(t) { + const repo = createRepo(t); + repo.write("docs/guide.md", "# Guide\n\nVersion one.\n"); + repo.write("docs/fr/guide.md", "placeholder\n"); + repo.commit("source"); + repo.write("docs/fr/guide.md", stamped(repo.git("rev-parse", "HEAD:docs/guide.md"), "# Guide\n")); + const base = repo.commit("translation"); + return { repo, base }; +} + +test("check --working-tree reports a source change that is not committed yet", (t) => { + const { repo, base } = stampedRepo(t); + repo.write("docs/guide.md", "# Guide\n\nVersion two.\n"); + + const { report } = repo.report("--base", base, "--working-tree"); + + assert.deepEqual( + report.findings.map((entry) => [entry.status, entry.path]), + [["stale", "docs/fr/guide.md"]] + ); +}); + +test("check --working-tree reports a new source page that git does not track yet", (t) => { + const { repo, base } = stampedRepo(t); + repo.write("docs/new.md", "# New\n"); + + const { report } = repo.report("--base", base, "--working-tree"); + + assert.deepEqual( + report.findings.map((entry) => [entry.status, entry.path]), + [["missing", "docs/fr/new.md"]] + ); +}); + +test("check --working-tree reports a translation orphaned by an uncommitted deletion", (t) => { + const { repo, base } = stampedRepo(t); + fs.rmSync(path.join(repo.root, "docs/guide.md")); + + const { report } = repo.report("--base", base, "--working-tree"); + + assert.deepEqual( + report.findings.map((entry) => [entry.status, entry.path]), + [["orphaned", "docs/fr/guide.md"]] + ); +}); + +test("check without --working-tree keeps uncommitted changes out of the report", (t) => { + const { repo, base } = stampedRepo(t); + repo.write("docs/guide.md", "# Guide\n\nVersion two.\n"); + + const { report } = repo.report("--base", base, "--head", "HEAD"); + + assert.deepEqual(report.findings, []); +}); + +test("--working-tree refuses to be combined with --head", (t) => { + const { repo, base } = stampedRepo(t); + + const { code, stderr } = repo.run("--base", base, "--head", "HEAD", "--working-tree"); + + assert.match(stderr, /--working-tree.*--head/); + assert.equal(code, 1); +}); From 5214129bcb1e1499f68043eb0bf40021544f1312 Mon Sep 17 00:00:00 2001 From: Chancel WOROU Date: Mon, 21 Sep 2026 13:44:39 +0100 Subject: [PATCH 2/3] feat(i18n): add drift check documentation and GitHub workflow Add scripts/i18n-drift-check.md, the usage guide referenced in the script header, and the GitHub Actions workflow that posts the drift report on pull requests and opens an issue when drift reaches master. Relates to #592 Co-authored-by: MaximeNzo <58881605+MaximeNzo@users.noreply.github.com> Co-authored-by: Nounagnon02 <171674631+Nounagnon02@users.noreply.github.com> --- .github/workflows/i18n-translation-drift.yml | 160 +++++ scripts/i18n-drift-check.md | 653 +++++++++++++++++++ 2 files changed, 813 insertions(+) create mode 100644 .github/workflows/i18n-translation-drift.yml create mode 100644 scripts/i18n-drift-check.md diff --git a/.github/workflows/i18n-translation-drift.yml b/.github/workflows/i18n-translation-drift.yml new file mode 100644 index 000000000..78800a98a --- /dev/null +++ b/.github/workflows/i18n-translation-drift.yml @@ -0,0 +1,160 @@ +name: i18n translation drift + +on: + pull_request: + paths: + - "docs/**/*.md" + - "docs/.vuepress/config.js" + - "scripts/i18n-drift-check.mjs" + push: + branches: + - master + paths: + - "docs/**/*.md" + - "docs/.vuepress/config.js" + - "scripts/i18n-drift-check.mjs" + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + report-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate i18n drift report + run: | + node scripts/i18n-drift-check.mjs \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" \ + --json-out i18n-report.json \ + --markdown-out i18n-report.md + + - name: Upsert pull request comment + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const body = fs.readFileSync("i18n-report.md", "utf8"); + const marker = ""; + const finalBody = `${marker}\n${body}`; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((comment) => + comment.user?.type === "Bot" && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: finalBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: finalBody, + }); + } + + - name: Upload i18n report artifact + uses: actions/upload-artifact@v4 + with: + name: i18n-report-pr + path: | + i18n-report.json + i18n-report.md + + create-issue-on-master: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate i18n drift report + run: | + node scripts/i18n-drift-check.mjs \ + --base "${{ github.event.before }}" \ + --head "${{ github.sha }}" \ + --json-out i18n-report.json \ + --markdown-out i18n-report.md + + - name: Create issue when drift exists + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const report = JSON.parse(fs.readFileSync("i18n-report.json", "utf8")); + const md = fs.readFileSync("i18n-report.md", "utf8"); + + if (!report.hasDrift) { + core.info("No translation drift detected; no issue created."); + return; + } + + const { owner, repo } = context.repo; + + const baseLabels = [ + { name: "i18n", color: "e4e669" }, + { name: "translation-needed", color: "e4e669" }, + ]; + const localeLabels = report.locales.map((locale) => ({ + name: `locale:${locale}`, + color: "0075ca", + })); + const allLabels = [...baseLabels, ...localeLabels]; + + for (const label of allLabels) { + try { + await github.rest.issues.createLabel({ owner, repo, ...label }); + } catch { + // label already exists + } + } + + const title = `i18n: translation updates required for ${context.sha.slice(0, 7)}`; + const body = [ + "This issue was auto-created after source documentation changes were merged to `master`.", + "", + md, + "", + "Please coordinate locale updates with maintainers/reviewers.", + ].join("\n"); + + await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: allLabels.map((l) => l.name), + }); + + - name: Upload i18n report artifact + uses: actions/upload-artifact@v4 + with: + name: i18n-report-master + path: | + i18n-report.json + i18n-report.md diff --git a/scripts/i18n-drift-check.md b/scripts/i18n-drift-check.md new file mode 100644 index 000000000..3ab8257fa --- /dev/null +++ b/scripts/i18n-drift-check.md @@ -0,0 +1,653 @@ +# Translation drift check + +`scripts/i18n-drift-check.mjs` tells you which translated pages no longer match the English +documentation, and shows translators exactly what changed. + +**AI Disclosure** This document includes content generated with assistance from Claude. All content has been reviewed and validated by the author. + +This guide covers what the script does, every command, how to try it safely, what the tests +check, and the reasons behind the design. + +- [The idea in one minute](#the-idea-in-one-minute) +- [Before you start](#before-you-start) +- [Commands at a glance](#commands-at-a-glance) +- [What the statuses mean](#what-the-statuses-mean) +- [How to use it, by role](#how-to-use-it-by-role) +- [Each command in detail](#each-command-in-detail) +- [Reading the report](#reading-the-report) +- [The baseline and out-of-scope languages](#the-baseline-and-out-of-scope-languages) +- [What happens on GitHub](#what-happens-on-github) +- [Try it yourself](#try-it-yourself) +- [The tests](#the-tests) +- [What the script cannot know](#what-the-script-cannot-know) +- [Troubleshooting](#troubleshooting) +- [Design notes](#design-notes) + +## The idea in one minute + +English pages live under `docs/`. Each translation lives at the same path under +`docs//`: + +``` +docs/technical/guide.md English source +docs/fr/technical/guide.md French translation +docs/pt/technical/guide.md Portuguese translation +``` + +Every translated page records **which version of the English page it was made from**. The +record is a fingerprint of the English text, written at the top of the translated file: + +```yaml +--- +i18n_source_sha: 7e45a52f21d78bfc6c6233abd7e8b2a73b007a10 +--- +``` + +We call this line the **stamp**. The fingerprint is git's own hash of the English file: +change one character of the English page and the fingerprint changes completely. + +To judge a translation, the script computes the fingerprint of the English page as it is +today and compares: + +| Recorded stamp vs. English page today | Verdict | +| --- | --- | +| same | the translation is **up to date** | +| different | the English page changed after the translation was made: the translation is **stale** | +| no stamp | **unstamped**: the script cannot tell | + +The script never reads the translated text and does not understand any language. It answers +one question reliably: *did the English page change since this translation was stamped?* + +All languages are treated the same way. Examples below use French (`fr`), but every command +covers every language directory. + +## Before you start + +- Node.js 22 (see `.nvmrc`) and git. +- Run every command from the repository root. +- No `npm install` is needed for the script or its tests: they only use Node built-ins. + +## Commands at a glance + +| Command | What it does | Changes files? | +| --- | --- | --- | +| `npm run i18n:audit` | Reports the state of every translation in the repository, with coverage percentages | no | +| `npm run i18n:check` | Reports what your branch did to English pages, uncommitted work included, and whether translations followed | writes `i18n-report.md` / `.json` (git-ignored) | +| `npm run i18n:explain -- ` | Shows the English lines that changed since that page was translated | no | +| `npm run i18n:stamp` | Stamps translated pages that have **no stamp yet** | yes, translated pages | +| `npm run i18n:stamp -- ` | Refreshes the stamp of that one page | yes, that page | +| `npm run i18n:stamp -- --changed` | Refreshes the stamp of every translated page you edited on your branch | yes, those pages | +| `npm run i18n:stamp -- --force` | Refreshes every stamp, including stale ones. Use with care | yes, translated pages | +| `npm run i18n:baseline` | Records today's known gaps so they stop counting as new | writes `scripts/i18n-baseline.json` | +| `npm run i18n:test` | Runs the script's automated tests | no | + +The `--` after the npm script name is required: it tells npm that what follows is meant for +the script. + +## What the statuses mean + +| Status | Meaning | What to do | +| --- | --- | --- | +| `missing` | The English page has no translation in this language | Translate it, then stamp it | +| `stale` | The stamp no longer matches the English page | Run `i18n:explain`, carry the changes across, re-stamp | +| `orphaned` | The translation exists but its English page was deleted or renamed | Delete or move the translation | +| `unstamped` | The translation exists but has no stamp | Stamp it (see first-time setup) | +| `needs_review` | Only in `i18n:check`: the English page changed, and the *unstamped* translation was not touched on this branch. An estimate, not a proof | Check the page, then stamp it | +| `updated` | The translation matches the current English page | Nothing | + +## How to use it, by role + +### I translate pages + +**I translated a new page.** + +```bash +npm run i18n:stamp -- docs/fr/path/to/page.md +``` + +Commit the translated page with its stamp. + +**The report says one of my pages is `stale`.** + +1. See what changed in English: + + ```bash + npm run i18n:explain -- docs/fr/path/to/page.md + ``` + +2. Carry those changes into the translation. +3. Re-stamp the page. This is your statement that the translation now matches: + + ```bash + npm run i18n:stamp -- docs/fr/path/to/page.md + ``` + +**I updated several pages in one sitting.** Stamp them all at once: + +```bash +npm run i18n:stamp -- --changed +``` + +**The English change does not affect the translation** (a typo fixed in English, for +example). The translated file does not need editing, but its stamp is now behind. Name the +page to re-stamp it: + +```bash +npm run i18n:stamp -- docs/fr/path/to/page.md +``` + +`--changed` would skip it, because you did not edit the translated file. + +**Only stamp what you really updated.** Stamping a page says "this translation matches the +current English page". The script trusts you. + +### I change English pages + +Nothing is required. When you open a pull request, a bot comments with the list of +translations your change leaves behind. To see that list before you commit or push, run +`npm run i18n:check`. The comment is information, not a blocker. If you can update the +translations yourself, do it in the same pull request and stamp them; otherwise translators +will pick them up from the issue created after merge. + +If you rename or delete an English page, rename or delete its translations too, or they +will be reported as `orphaned`. + +### I maintain the repository + +**First-time setup.** Until pages carry stamps, the script cannot detect anything. + +1. Decide which languages are in scope. A language whose translation has just started + reports every page not translated yet as `missing`: several hundred for a language that + only has a home page. That is accurate, and it is a usable to-do list. If the team + prefers to keep such a language out of the reports until its translation is further + along, see [the baseline section](#the-baseline-and-out-of-scope-languages). +2. Stamp every existing translation: + + ```bash + npm run i18n:stamp + ``` + + This declares "all current translations are up to date as of today". Nobody can verify + that page by page; it is the starting line from which drift is measured. +3. Record the gaps that remain (missing and orphaned pages) as known debt: + + ```bash + npm run i18n:baseline + ``` + +4. Commit the stamped pages and `scripts/i18n-baseline.json`. + +**Blocking pull requests (optional, a team decision).** By default the script only reports. +Adding `--fail-on` to the command in `.github/workflows/i18n-translation-drift.yml` makes the +job fail when a pull request creates *new* drift: + +```bash +node scripts/i18n-drift-check.mjs --base ... --head ... --fail-on missing,orphaned,stale +``` + +Do this only after the baseline exists, or every pull request will fail on old gaps. + +## Each command in detail + +All `npm run i18n:*` commands call `node scripts/i18n-drift-check.mjs` with different +options. `node scripts/i18n-drift-check.mjs --help` lists the options. + +### `npm run i18n:audit` + +Scans the whole repository as it is on disk, committed or not. For every English page and +every language, it reports `missing`, `stale`, `unstamped` or `orphaned` pages, plus a +coverage table. Pages that are fine are not listed. + +Useful options: + +```bash +node scripts/i18n-drift-check.mjs --audit --markdown-out i18n-report.md --json-out i18n-report.json +``` + +Open `i18n-report.md` in your editor's Markdown preview for a readable version. Both report +files are git-ignored. + +### `npm run i18n:check` + +Runs: + +```bash +node scripts/i18n-drift-check.mjs --base origin/master --working-tree --markdown-out i18n-report.md --json-out i18n-report.json +``` + +1. Asks git which files changed since your branch left `--base`. +2. Keeps the English pages only. +3. For each one, and for each language, gives a verdict: `orphaned`, `missing`, `updated`, + `stale`, or, for unstamped pages, an estimate (`updated` if the translation was edited + on the same branch, `needs_review` if not). + +Where the range ends depends on one option: + +| Option | The range ends at | Sees uncommitted work? | Used by | +| --- | --- | --- | --- | +| `--working-tree` | the files on your disk: committed, staged, saved, or new and not yet tracked by git | yes | `npm run i18n:check` | +| `--head ` (default `HEAD`) | a commit | no | GitHub, which checks exactly the commits of the pull request | + +The report's `Range:` line tells you which one ran: `origin/master...working tree` or +`...`. The two options cannot be combined. + +If `origin/master` does not exist locally, the script falls back to `master`, then +`origin/main`, then `main`, and says so. If none exists it stops rather than report a clean +result it cannot back up. + +### `npm run i18n:explain -- ` + +Prints the difference between the English page the translation was made from and the +English page today: + +``` +docs/guide/install.md changed since docs/fr/guide/install.md was translated (794419b -> a23119c). +Carry these changes across, then run: npm run i18n:stamp -- docs/fr/guide/install.md + +diff --git a/docs/guide/install.md b/docs/guide/install.md +index 794419b..a23119c 100644 +--- a/docs/guide/install.md ++++ b/docs/guide/install.md +@@ -1,3 +1,3 @@ + # Install + +-Run the installer. ++Run the installer as administrator. +``` + +The first four lines after the blank line are git's header and can be ignored. Below them, +a line starting with `-` was removed from the English page and a line starting with `+` was +added. It works on uncommitted English changes too. + +Other answers you can get: + +| Message | Meaning | +| --- | --- | +| `... is up to date with ...` | Nothing to do | +| `carries no i18n_source_sha` | The page was never stamped, so there is no starting point to compare with | +| `records source version 1111111, which was not found in this repository` | The stamp was typed by hand, or your clone is shallow (`git fetch --unshallow`) | +| `... no longer exists: the translation is orphaned` | The English page was deleted or renamed | + +### `npm run i18n:stamp` + +The only command that edits documentation files. It never runs in CI. + +It touches only the stamp line. If the page has no front matter block (`---` ... `---`) at +the top, it creates one. Everything else in the file is left byte for byte as it was. + +| Form | Pages considered | Overwrites a stale stamp? | +| --- | --- | --- | +| `npm run i18n:stamp` | every translated page | **no**: only fills in pages with no stamp | +| `npm run i18n:stamp -- ` | that page | yes | +| `npm run i18n:stamp -- --changed` | translated pages that differ from the base branch: committed, staged, saved, or not yet tracked by git | yes | +| `npm run i18n:stamp -- --force` | every translated page | yes | + +`--changed` compares with `origin/master` by default. Use `--base` to compare with +something else. For example, only your uncommitted work: + +```bash +npm run i18n:stamp -- --changed --base HEAD +``` + +(`HEAD` is git's name for your last commit.) + +The command ends with a count of what it did: + +``` +[i18n-check] Stamped translations with i18n_source_sha: 256 front matter created, 80 key added, +0 updated, 0 already current, 1 out of date left untouched. +``` + +| Count | Meaning | +| --- | --- | +| `front matter created` | the page had no front matter; one was created with the stamp | +| `key added` | the page had front matter; the stamp line was added | +| `updated` | a stale stamp was replaced | +| `already current` | the stamp already matched; nothing written | +| `out of date left untouched` | a stale stamp was found and deliberately kept, so the drift stays visible | + +### `npm run i18n:baseline` + +Runs a full audit and writes every `missing`, `stale` and `orphaned` finding to +`scripts/i18n-baseline.json`. See [below](#the-baseline-and-out-of-scope-languages). + +### Options and exit codes + +| Option | Meaning | +| --- | --- | +| `--base ` | Start of the git range (default `origin/master`) | +| `--head ` | End of the git range (default `HEAD`) | +| `--working-tree` | End the range at the files on disk instead of a commit, so uncommitted and untracked changes are reported | +| `--audit` | Scan the whole repository instead of a git range | +| `--json-out `, `--markdown-out ` | Write the report to a file | +| `--fail-on ` | Exit with code 2 on *new* findings of these statuses, e.g. `missing,orphaned,stale` | +| `--update-baseline` | With `--audit`: rewrite the baseline | +| `--stamp [path]`, `--changed`, `--force` | See `i18n:stamp` | +| `--explain ` | See `i18n:explain` | + +| Exit code | Meaning | +| --- | --- | +| 0 | Clean, or report-only | +| 1 | Usage or configuration error | +| 2 | New drift found and `--fail-on` asked to block | + +## Reading the report + +A pull-request report looks like this: + +```markdown +## Translation Drift Report + +Locales: `fr` +Range: `50f8c8c...HEAD` + +| Status | New | Known debt | Meaning | +| --- | ---: | ---: | --- | +| โŒ missing | 1 | 0 | translation does not exist | +| โš ๏ธ stale | 1 | 0 | translation records an older version of the source | + +### Needs attention (2) + +**`fr`** + +- โŒ `docs/fr/guide/faq.md` (missing) +- โš ๏ธ `docs/fr/guide/install.md` (stale) โ€” translates source 794419b, current source is a23119c + +> Verdicts are exact: each translation records the `i18n_source_sha` it was made from ... +``` + +- **New** counts findings that are not in the baseline. **Known debt** counts the ones that + are: still shown, never blocking. +- **Needs attention** lists the new findings, grouped by language. +- The last line tells you how far to trust the verdicts. If some pages have no stamp, it + says that their verdict "is inferred from the diff alone and may be wrong". + +An audit report adds a coverage table per language: + +```markdown +### Coverage - `fr` + +| Section | Source pages | Translated | Up to date | +| --- | ---: | ---: | ---: | +| `getting-started` | 18 | 13 (72%) | 0 (0%) | +| `technical` | 185 | 184 (99%) | 0 (0%) | +| **all** | 349 | 339 (97%) | 0 (0%) | +``` + +| Column | Counts | +| --- | --- | +| Section | the top-level folder under `docs/`; `(root)` is for pages directly in `docs/` | +| Source pages | English pages in that folder | +| Translated | how many have a translated file. Existence only | +| Up to date | how many have a stamp that matches the English page today | + +`Up to date` at 0% does not mean the translations are bad. It means none is stamped yet, so +nothing can be proven. After first-time setup it jumps to the `Translated` figure, then +drops each time an English page changes without its translation following. + +## The baseline and out-of-scope languages + +`scripts/i18n-baseline.json` holds two team decisions. + +**Known debt (`entries`).** Gaps accepted when the file was generated. They are still +reported, in the "Known debt" column, but they do not count as new and never trigger +`--fail-on`. This lets CI block *new* drift without first fixing every old gap. Regenerate +with `npm run i18n:baseline` when gaps are closed. + +**Out-of-scope languages (`ignoredLocales`).** A language listed here is left out of every +report and is never stamped. Its directory is still recognised as a translation, so its +pages are not mistaken for English pages. The key is the language code; the value is a free +note: + +```json +{ + "ignoredLocales": { "de": "translation not started yet" }, + "entries": [] +} +``` + +Regenerating the baseline keeps `ignoredLocales` as it is. Set it *before* generating the +baseline; otherwise the ignored language's missing pages are recorded as known debt first. + +## What happens on GitHub + +`.github/workflows/i18n-translation-drift.yml` runs when English or translated pages, the +VuePress config, or the script change. + +| Event | What runs | Result | +| --- | --- | --- | +| Pull request | the check, over the commits of the pull request | one bot comment with the report, updated on each push | +| Push to `master` | the check, over the pushed commits | if there is new drift, an issue labelled `i18n`, `translation-needed` and `locale:` | + +This workflow runs on GitHub Actions, next to the repository's CircleCI pipeline +(`.circleci/config.yml`) and independently of it: CircleCI builds and publishes the site, +this workflow only reports on translations. It needs the permissions declared at the top of +the file: read the contents, write pull-request comments, and write issues. + +Both jobs compare commits (`--head`), never `--working-tree`: on GitHub there is no work in +progress, and the report must describe exactly what is being merged. + +Neither job fails the build today. See [blocking pull requests](#i-maintain-the-repository). + +## Try it yourself + +A ten-minute tour on a real page. Steps 3 to 7 edit files under `docs/`; step 8 undoes +everything. + +**Before you start**, run `git status --short` and make sure you have no work in progress +under `docs/`, because step 8 discards all uncommitted changes there. + +1. **Run the tests.** + + ```bash + npm run i18n:test + ``` + + Expect `pass 20`, `fail 0`. + +2. **Look at the coverage.** + + ```bash + node scripts/i18n-drift-check.mjs --audit --markdown-out i18n-report.md + ``` + + Open `i18n-report.md` in Markdown preview and find the `Coverage` tables. + +3. **Stamp one page.** + + ```bash + npm run i18n:stamp -- docs/fr/index.md + git hash-object docs/index.md + ``` + + Open `docs/fr/index.md`: an `i18n_source_sha:` line appeared at the top. Its value is + exactly what the second command prints: the stamp is git's fingerprint of the English + page. + +4. **Confirm the page is up to date.** + + ```bash + npm run i18n:explain -- docs/fr/index.md + ``` + + Expect `docs/fr/index.md is up to date with docs/index.md`. + +5. **Pretend the English page changed.** Add any sentence at the end of `docs/index.md`, + save, and run step 4 again. Your sentence is shown with a `+` in front. Run step 2 + again: the report now shows `stale | 1`. + + The order of steps 3 and 5 matters. If you change the English page *before* stamping, + the stamp records the changed version and no drift is detected. + +6. **See the bulk-stamp safeguard.** + + ```bash + npm run i18n:stamp + ``` + + This writes a stamp into every unstamped translation (several hundred files; step 8 + undoes it). The summary ends with `1 out of date left untouched`: the stale page from + step 5 was *not* marked up to date. + +7. **Re-stamp the page you "updated".** + + ```bash + npm run i18n:stamp -- docs/fr/index.md + npm run i18n:explain -- docs/fr/index.md + ``` + + Expect `1 updated`, then `is up to date`. To try `--changed` instead, edit an already + stamped translated page, save, and run `npm run i18n:stamp -- --changed --base HEAD`. + +8. **Put everything back.** + + ```bash + git checkout -- docs + git status --short + ``` + +9. **Run the pull-request check locally.** Add a sentence to `docs/index.md` again, save, + do not commit, and run: + + ```bash + npm run i18n:check + ``` + + The report's range reads `...working tree`, and `docs/fr/index.md` is listed even though + nothing was committed. Undo the edit with `git checkout -- docs/index.md`. + +## The tests + +```bash +npm run i18n:test +``` + +The tests live in `scripts/i18n-drift-check.test.mjs` and take about fifteen seconds. Each test +builds a small throwaway git repository in the system's temporary folder, puts a few pages +in it, runs the real script as a command, checks the result, and deletes the folder. They +never touch this repository. + +A `โœ”` line is a passing test, a `โœ–` line is a failing one, followed by what was expected +and what was found. `fail 0` at the bottom is the line to look for. Run the tests after any +change to the script. + +In the table, "old stamp" means a fake stamp that matches nothing, which makes the page +stale. + +| # | Setup | Command | Must be true | +| --- | --- | --- | --- | +| 1 | Repository configured like a Windows checkout (`core.autocrlf=true`), English page saved with Windows line endings, unstamped translation | `--stamp` | The stamp written equals the hash git stores for the English page | +| 2 | Same kind of repository, translation stamped with git's hash | `--audit` | No finding at all | +| 3 | Translation with an old stamp; baseline generated; then the English page changes and is committed | check with `--fail-on stale,needs_review` | The page is `stale`, marked as known debt, and the exit code is 0 | +| 4 | Translation with an old stamp | `--stamp` | The old stamp is still there; output says `1 out of date left untouched` | +| 5 | Unstamped translation | `--stamp` | The page gets the correct stamp | +| 6 | Two translations with old stamps | `--stamp docs/fr/one.md` | `one.md` is refreshed, `two.md` keeps its old stamp | +| 7 | Translation with an old stamp | `--stamp --force` | The stamp is refreshed | +| 8 | Translation stamped, then the English text changes from "Version one." to "Version two." | `--explain` | Output contains `-Version one.` and `+Version two.`; exit code 0 | +| 9 | Translation stamped with the current English version | `--explain` | Output says `up to date`; exit code 0 | +| 10 | Translation with a stamp git has never seen | `--explain` | Clear error `... not found in this repository`; exit code 1 | +| 11 | Unstamped translation | `--explain` | Clear error mentioning `i18n_source_sha`; exit code 1 | +| 12 | Section `a`: one page translated and current, one not translated. Section `b`: one page translated but unstamped | `--audit` | JSON coverage for `fr` is 3 pages, 2 translated, 1 up to date, with the right split per section | +| 13 | Same as 12 | `--audit` | The printed report contains the coverage table with the right percentages | +| 14 | Two translations with old stamps, committed; then one is edited and *not* committed | `--stamp --changed` | Only the edited page is refreshed | +| 15 | One unstamped translation committed; then a brand-new translation that git does not track yet | `--stamp --changed` | The new page is stamped; the untouched page is left unstamped | +| 16 | Stamped, up-to-date translation committed; then the English page is edited and *not* committed | check with `--working-tree` | The translation is reported `stale` | +| 17 | Same start; then a new English page is created that git does not track yet | check with `--working-tree` | Its translation is reported `missing` | +| 18 | Same start; then the English page is deleted and the deletion is *not* committed | check with `--working-tree` | The translation is reported `orphaned` | +| 19 | Same as 16 | check with `--head HEAD` | No finding: without `--working-tree`, uncommitted work stays out, which is what CI relies on | +| 20 | Same start | check with both `--head` and `--working-tree` | Clear error naming both options; exit code 1 | + +Tests 1 to 4 each reproduce a bug that existed (see [design notes](#design-notes)). Tests 5 +to 7 make sure the safeguard from test 4 did not break normal stamping. + +## What the script cannot know + +| The script knows | The script does not know | +| --- | --- | +| That the English page changed since the last stamp. Always, exactly | Whether the translation is complete | +| Which English lines changed | Whether the translation is correct | +| | Whether the change was really carried across before the page was re-stamped | + +Stamping is a statement made by a person. Two things keep it honest: + +- a bare `npm run i18n:stamp` never refreshes a stale stamp, so nobody does it by accident; +- a refreshed stamp shows up as a changed line in the pull request, where a reviewer can + check that the translation changed with it. + +## Troubleshooting + +**I changed an English page and nothing is reported.** Most likely the translation is not +stamped: an audit then shows it as `unstamped`, not `stale`, because there is nothing to +compare with. If you called the script by hand with `--head`, uncommitted changes are left +out; use `npm run i18n:check`, which passes `--working-tree`. + +**Everything is `unstamped` and "Up to date" is 0%.** First-time setup has not been done. +See [I maintain the repository](#i-maintain-the-repository). + +**Hundreds of `missing` pages for one language.** Its translation has just started, and +every page not translated yet counts as `missing`. The number drops as pages are +translated. To keep that language out of the reports for now, add it to `ignoredLocales`. + +**`Warning: docs// exists but is not declared in docs/.vuepress/config.js`.** The +directory is treated as a language, but VuePress will not show it in the language switcher +until it is declared in the config. Harmless for the script. + +**`Base ref "origin/master" not found. Falling back to "master".`** Harmless. Run +`git fetch origin` to compare with the latest upstream state. + +**`git diff ... failed` in CI.** The checkout is shallow. Use `actions/checkout` with +`fetch-depth: 0`. + +**`--explain` says the recorded version was not found.** The stamp was written by hand or +comes from a version of the English page that was never committed. Re-translate against the +current page and re-stamp. + +**A page became `stale` right after stamping.** It was stamped while the English page had +uncommitted changes, and those changes were then discarded. Re-stamp it. + +## Design notes + +**Why a content hash, not "was the file edited?" or commit dates.** An earlier version asked +whether the translated file was touched in the same change. That measures the gesture, not +the content: adding a comma to the French page made it look current. Commit dates are no +better: a squash or a rebase rewrites them, and the whole French translation landed in one +squashed commit, so every page shared a date. Nothing done inside the translated file can +change the hash of the English file. + +**Why the hash comes from git.** The script asks `git hash-object` rather than hashing the +bytes on disk. With `core.autocrlf`, a Windows checkout holds CRLF line endings where the +repository stores LF, so hashing raw bytes gives a different answer on a translator's +Windows machine than in CI on Linux, and every page stamped on Windows would be reported +stale. `git hash-object` applies the same line-ending rules as `git add`. All files are +hashed in one git call. + +**Why git is called without a shell.** On Windows, `cmd.exe` removes the `^` of +`^{commit}`, so no base branch could ever be resolved and the check mode failed on +every Windows machine. Calling git directly also means a branch name can never be read as +shell syntax. + +**Why a bare `--stamp` refuses to refresh stale stamps.** A translator who updates one page +and then stamps everything would silently mark every other stale page as current. A false +"up to date" cannot be recovered; a stale warning can. Refreshing therefore takes a page +name, `--changed`, or `--force`. + +**Why `npm run i18n:check` looks at the working tree, and CI does not.** A person at their +desk wants to know what their change will leave behind *before* committing, and a commit +range cannot see work in progress. CI is the opposite case: it must describe exactly the +commits being merged, so it keeps an explicit `--base`/`--head` range. The verdicts +themselves always came from the files on disk; `--working-tree` makes the list of changed +pages come from the same place. + +**Why both modes use the word `stale`.** The baseline is written by an audit and matched on +status and path. When the check mode used a different status name for the same condition, +known debt was never recognised and showed up as new. `needs_review` remains only for the +weaker verdict on unstamped pages. + +**Why languages come from both the config and the disk.** A language directory can exist +before anyone declares it in the VuePress config. If it were not recognised, its pages +would be treated as English pages and the script would demand translations *of them*. The +script uses both sources and warns about the difference. + +**Why first-time stamping is acceptable.** Stamping every page at once asserts something +nobody verified. It is the same bargain as the baseline: freeze the past, watch what comes +next. From 40befb786bb138cbe994c878a3db2c83dccabf0c Mon Sep 17 00:00:00 2001 From: Chancel WOROU Date: Sun, 18 Oct 2026 15:51:09 +0100 Subject: [PATCH 3/3] fix(i18n): reuse one open drift issue instead of opening one per push The master job created a new issue on every push with drift. It now looks for an open issue carrying a hidden marker, as the PR job does for its comment, and adds the new report to it as a comment. A new issue is only created when none is open. Relates to #592 Co-authored-by: MaximeNzo <58881605+MaximeNzo@users.noreply.github.com> Co-authored-by: Nounagnon02 <171674631+Nounagnon02@users.noreply.github.com> --- .github/workflows/i18n-translation-drift.yml | 41 +++++++++++++++++--- scripts/i18n-drift-check.md | 2 +- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/i18n-translation-drift.yml b/.github/workflows/i18n-translation-drift.yml index 78800a98a..b8bf42120 100644 --- a/.github/workflows/i18n-translation-drift.yml +++ b/.github/workflows/i18n-translation-drift.yml @@ -134,11 +134,42 @@ jobs: } } - const title = `i18n: translation updates required for ${context.sha.slice(0, 7)}`; + // One running issue, not one per push: the same marker lookup the PR job uses for + // its comment. Later pushes with drift add their report to it as a comment. + const marker = ""; + const labels = allLabels.map((l) => l.name); + const commitUrl = `${context.serverUrl}/${owner}/${repo}/commit/${context.sha}`; + const section = [`**Changes merged in [${context.sha.slice(0, 7)}](${commitUrl})**`, "", md].join("\n"); + + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + labels: "translation-needed", + per_page: 100, + }); + const existing = openIssues.find((issue) => + !issue.pull_request && issue.user?.type === "Bot" && issue.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.addLabels({ owner, repo, issue_number: existing.number, labels }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: existing.number, + body: section, + }); + core.info(`Added the report to the open issue #${existing.number}.`); + return; + } + const body = [ - "This issue was auto-created after source documentation changes were merged to `master`.", + marker, + "This issue tracks translation updates needed after source documentation changes were merged to `master`.", + "Later merges that leave translations behind add their report here as a comment. Close it once the translations have caught up.", "", - md, + section, "", "Please coordinate locale updates with maintainers/reviewers.", ].join("\n"); @@ -146,9 +177,9 @@ jobs: await github.rest.issues.create({ owner, repo, - title, + title: "i18n: translation updates required", body, - labels: allLabels.map((l) => l.name), + labels, }); - name: Upload i18n report artifact diff --git a/scripts/i18n-drift-check.md b/scripts/i18n-drift-check.md index 3ab8257fa..b5837c10a 100644 --- a/scripts/i18n-drift-check.md +++ b/scripts/i18n-drift-check.md @@ -417,7 +417,7 @@ VuePress config, or the script change. | Event | What runs | Result | | --- | --- | --- | | Pull request | the check, over the commits of the pull request | one bot comment with the report, updated on each push | -| Push to `master` | the check, over the pushed commits | if there is new drift, an issue labelled `i18n`, `translation-needed` and `locale:` | +| Push to `master` | the check, over the pushed commits | if there is new drift, one running issue labelled `i18n`, `translation-needed` and `locale:`: created on the first drift, then each later push with drift adds its report to it as a comment, until someone closes it | This workflow runs on GitHub Actions, next to the repository's CircleCI pipeline (`.circleci/config.yml`) and independently of it: CircleCI builds and publishes the site,