diff --git a/.github/workflows/translation-sync.yml b/.github/workflows/translation-sync.yml new file mode 100644 index 00000000..b49c7f2f --- /dev/null +++ b/.github/workflows/translation-sync.yml @@ -0,0 +1,56 @@ +name: translation-sync + +# Content-validation guardrails, kept out of ci.yml (which is build/test/lint): +# - blocking: all README*.md translations share the same ## section structure. +# - non-blocking: warn when a docs/en page changes without its zh/ja counterpart. +# Scoped with paths: so it only runs when a translation or the checker changes, +# and a slow container pull here never delays core CI feedback. + +on: + push: + paths: + - "README*.md" + - "pages/src/content/docs/**" + - "scripts/github-actions/check-translation-sync.*" + pull_request: + paths: + - "README*.md" + - "pages/src/content/docs/**" + - "scripts/github-actions/check-translation-sync.*" + +jobs: + translation-sync: + runs-on: self-hosted + timeout-minutes: 10 + container: + # Pinned to an exact 24.x for reproducible builds, matching the + # golang:1.26.5 pin used by the other jobs (avoids the mutable node:24 tag). + image: node:24.18.0 + steps: + # fetch-depth: 0 so the non-blocking docs check can diff base...head. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Trust workspace + run: git config --global --replace-all safe.directory '*' + + # Validate the guardrail script itself (plain Node, no deps). + - name: Test translation-sync guardrail + run: node scripts/github-actions/check-translation-sync.test.js + + # BLOCKING: all README*.md translations must share an identical ## + # (level-2) section structure. Fails with an ::error annotation on divergence. + - name: Check README translation structure + run: node scripts/github-actions/check-translation-sync.js readmes + + # NON-BLOCKING: on PRs, warn (::warning) when a file under + # pages/src/content/docs/en/** changed without its zh/ja counterpart. + # continue-on-error keeps it advisory: it never fails the build. + - name: Check docs translation sync + if: ${{ github.event_name == 'pull_request' }} + continue-on-error: true + env: + OCR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + OCR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/github-actions/check-translation-sync.js docs diff --git a/package.json b/package.json index 3cfb004a..0e568efb 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "scripts": { "postinstall": "node scripts/install.js", - "test:github-actions": "node scripts/github-actions/post-review-comments.test.js" + "test:github-actions": "node scripts/github-actions/post-review-comments.test.js && node scripts/github-actions/check-translation-sync.test.js" }, "repository": { "type": "git", diff --git a/scripts/github-actions/check-translation-sync.js b/scripts/github-actions/check-translation-sync.js new file mode 100644 index 00000000..96fc8d4b --- /dev/null +++ b/scripts/github-actions/check-translation-sync.js @@ -0,0 +1,367 @@ +"use strict"; + +// OpenCodeReview translation-sync guardrails. +// +// Two independent checks, both runnable as plain Node (no npm deps, node >= 14, +// same convention as scripts/github-actions/post-review-comments.js): +// +// 1. README structure check (BLOCKING). All README*.md translations must +// share an identical `##` (level-2) heading structure. Because the files +// are translations, the heading TEXT differs by language, so this compares +// STRUCTURE not text: the level-2 heading count and the full heading +// outline (the ordered sequence of heading levels), which is +// language-independent. PR #424 normalized all five READMEs to an +// identical structure; this keeps them from drifting apart. Fails with a +// non-zero exit and `::error` annotations when any file diverges. +// +// 2. Docs translation-sync annotation (NON-BLOCKING). When a PR changes a +// file under pages/src/content/docs/en/** without also changing the +// matching zh/ja counterpart path, emit a `::warning` PR annotation. This +// never fails the build; it only nudges the author/reviewer. +// +// Invoked from .github/workflows/ci.yml: +// node scripts/github-actions/check-translation-sync.js readmes (blocking) +// node scripts/github-actions/check-translation-sync.js docs (non-blocking) +// +// The core logic is exported as pure functions so it can be unit-tested without +// touching the filesystem, GitHub, or git (see check-translation-sync.test.js). + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +// The five localized README files. README.md (English) is the reference every +// translation is compared against. Discovered names confirmed in the repo root. +const README_FILES = [ + "README.md", + "README.zh-CN.md", + "README.ja-JP.md", + "README.ko-KR.md", + "README.ru-RU.md", +]; + +// Docs live under pages/src/content/docs//**. English is authored under +// en/; zh/ and ja/ mirror the exact same relative sub-paths. +const DOCS_EN_PREFIX = "pages/src/content/docs/en/"; +const DOCS_LOCALES = ["zh", "ja"]; + +// --------------------------------------------------------------------------- +// Markdown heading parsing +// --------------------------------------------------------------------------- + +// Extract ATX headings (`#`..`######`), skipping any that live inside fenced +// code blocks (``` or ~~~). Fence-awareness matters: the READMEs contain bash +// snippets whose `# comment` lines would otherwise be misread as level-1 +// headings. Returns [{ level, text, line }] in document order (1-based line). +function extractHeadings(markdown) { + const lines = String(markdown).split(/\r?\n/); + const headings = []; + let fenceChar = null; // '`' or '~' while inside a fence, else null + let fenceLen = 0; // length of the opening fence run (CommonMark: the close + // must use the same char and be at least this long) + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line); + if (fence) { + const ch = fence[1][0]; + const len = fence[1].length; + if (fenceChar === null) { + fenceChar = ch; // opening fence + fenceLen = len; + } else if (fenceChar === ch && len >= fenceLen) { + fenceChar = null; // matching closing fence (same char, long enough) + fenceLen = 0; + } + // A shorter run of the same char inside the block (len < fenceLen) is + // just code content, so fall through and skip it below. + continue; + } + if (fenceChar !== null) continue; // inside a code fence + const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); + if (h) headings.push({ level: h[1].length, text: h[2].trim(), line: i + 1 }); + } + return headings; +} + +// Ordered list of level-2 (`##`) heading texts. +function level2Headings(markdown) { + return extractHeadings(markdown) + .filter((h) => h.level === 2) + .map((h) => h.text); +} + +// Language-independent structure fingerprint: the ordered sequence of heading +// levels for every heading (outside code fences). Text is intentionally ignored +// because translations differ in wording; only the outline SHAPE is comparable. +function structuralSignature(markdown) { + return extractHeadings(markdown).map((h) => h.level); +} + +function sameSequence(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +function firstDiffIndex(a, b) { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; + return n; // sequences agree up to the shorter one's length +} + +function describeHeading(h) { + if (!h) return "no heading (document ends)"; + return `a level-${h.level} heading ("${h.text}")`; +} + +// --------------------------------------------------------------------------- +// Check 1: README structure (blocking) +// --------------------------------------------------------------------------- + +// Compare the heading structure of translated READMEs against a reference. +// files: [{ name, content }] — the FIRST entry is the reference (English). +// Returns { ok, errors } where errors is [{ file, message }]. +function compareReadmeStructures(files) { + const errors = []; + if (files.length < 2) return { ok: true, errors }; + + const ref = files[0]; + const refHeadings = extractHeadings(ref.content); + const refSig = refHeadings.map((h) => h.level); + const refL2 = refSig.filter((l) => l === 2).length; + + for (let i = 1; i < files.length; i++) { + const f = files[i]; + const fHeadings = extractHeadings(f.content); + const fSig = fHeadings.map((h) => h.level); + const fL2 = fSig.filter((l) => l === 2).length; + + if (sameSequence(refSig, fSig)) continue; // structurally identical + + if (fL2 !== refL2) { + errors.push({ + file: f.name, + message: + `${f.name} has ${fL2} level-2 (##) heading(s), but the reference ` + + `${ref.name} has ${refL2}. All README translations must share an ` + + `identical ## section structure (see PR #424).`, + }); + } else { + const idx = firstDiffIndex(refSig, fSig); + errors.push({ + file: f.name, + message: + `${f.name} heading outline diverges from ${ref.name} at heading ` + + `#${idx + 1}: expected ${describeHeading(refHeadings[idx])}, found ` + + `${describeHeading(fHeadings[idx])}. A heading was reordered or its ` + + `level changed; README translations must share an identical ` + + `structure (see PR #424).`, + }); + } + } + return { ok: errors.length === 0, errors }; +} + +// --------------------------------------------------------------------------- +// Check 2: docs en -> zh/ja counterpart sync (non-blocking) +// --------------------------------------------------------------------------- + +// Given an English docs path, return its expected translation counterparts. +function counterpartPaths(enPath) { + const rest = enPath.slice(DOCS_EN_PREFIX.length); + return DOCS_LOCALES.map((locale) => ({ + locale, + path: `pages/src/content/docs/${locale}/${rest}`, + })); +} + +// From a changed-files list, find English docs that changed without their +// zh/ja counterpart also changing. Returns [{ enPath, locale, counterpart }]. +function findMissingTranslations(changedFiles) { + const set = new Set(changedFiles.map((p) => p.replace(/\\/g, "/"))); + const warnings = []; + for (const raw of changedFiles) { + const file = raw.replace(/\\/g, "/"); + if (!file.startsWith(DOCS_EN_PREFIX)) continue; + const lower = file.toLowerCase(); + if (!lower.endsWith(".md") && !lower.endsWith(".mdx")) continue; + for (const cp of counterpartPaths(file)) { + if (!set.has(cp.path)) { + warnings.push({ enPath: file, locale: cp.locale, counterpart: cp.path }); + } + } + } + return warnings; +} + +// --------------------------------------------------------------------------- +// GitHub Actions annotation + IO helpers +// --------------------------------------------------------------------------- + +function emitError(file, message) { + const loc = file ? ` file=${file}` : ""; + console.log(`::error${loc}::${message}`); +} + +function emitWarning(file, message) { + const loc = file ? ` file=${file}` : ""; + console.log(`::warning${loc}::${message}`); +} + +function splitList(raw) { + return String(raw) + .split(/[\r\n,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +// Resolve the PR's changed files. Precedence: an explicit OCR_CHANGED_FILES +// override (newline/comma separated), else a three-dot git diff between the +// base and head SHAs (needs fetch-depth: 0), else an empty set. +function getChangedFiles(env, exec = execFileSync) { + if (env.OCR_CHANGED_FILES) return splitList(env.OCR_CHANGED_FILES); + const base = env.OCR_BASE_SHA; + const head = env.OCR_HEAD_SHA; + if (base && head) { + try { + const out = exec("git", ["diff", "--name-only", `${base}...${head}`], { + encoding: "utf8", + }); + return splitList(out); + } catch (e) { + emitWarning( + null, + `Could not compute changed files via git diff (${e.message}); ` + + `skipping docs translation-sync check.` + ); + return []; + } + } + return []; +} + +function writeStepSummary(env, warnings) { + const file = env.GITHUB_STEP_SUMMARY; + if (!file || warnings.length === 0) return; + const lines = [ + "### Translation sync (non-blocking)", + "", + "These English docs changed without their translation counterparts:", + "", + ]; + for (const w of warnings) { + lines.push(`- \`${w.enPath}\` -> missing \`${w.counterpart}\` (${w.locale})`); + } + lines.push(""); + try { + fs.appendFileSync(file, lines.join("\n") + "\n"); + } catch (e) { + /* summary is best-effort */ + } +} + +// --------------------------------------------------------------------------- +// CLI runners +// --------------------------------------------------------------------------- + +// BLOCKING. Returns a process exit code (0 ok, 1 on divergence). +function runReadmeCheck({ repoRoot = process.cwd(), files = README_FILES } = {}) { + const loaded = []; + let missing = 0; + for (const name of files) { + try { + loaded.push({ name, content: fs.readFileSync(path.join(repoRoot, name), "utf8") }); + } catch (e) { + missing++; + emitError(name, `Expected README translation file ${name} is missing.`); + } + } + + // Short-circuit when any expected file is absent: comparing only the files + // that DID load can mask a real divergence (e.g. a missing reference would + // silently promote the next file to reference). A missing translation is + // itself a failure, so fail fast before the structure comparison. + if (missing > 0) { + emitError( + null, + "README translation structure check failed: one or more expected " + + "README*.md files are missing (see the file annotations above)." + ); + return 1; + } + + const { ok, errors } = compareReadmeStructures(loaded); + for (const err of errors) emitError(err.file, err.message); + + if (ok) { + console.log( + `README translation structure check passed: all ${loaded.length} ` + + `README files share the same ## section structure.` + ); + return 0; + } + emitError( + null, + "README translation structure check failed. All README*.md files must " + + "share an identical ## section structure." + ); + return 1; +} + +// NON-BLOCKING. Always returns exit code 0; only emits `::warning` annotations. +function runDocsCheck({ env = process.env } = {}) { + const changed = getChangedFiles(env); + const warnings = findMissingTranslations(changed); + if (warnings.length === 0) { + console.log( + "Docs translation-sync check: no en-only documentation changes detected." + ); + return 0; + } + for (const w of warnings) { + emitWarning( + w.enPath, + `Translation sync: ${w.enPath} changed but its ${w.locale} counterpart ` + + `${w.counterpart} was not updated in this PR. Update the translation ` + + `or note why it is intentionally out of sync.` + ); + } + console.log( + `Docs translation-sync check: ${warnings.length} missing translation ` + + `counterpart(s) (non-blocking).` + ); + writeStepSummary(env, warnings); + return 0; +} + +function main(argv = process.argv.slice(2), env = process.env) { + const mode = (argv[0] || "readmes").toLowerCase(); + const repoRoot = env.OCR_REPO_ROOT || process.cwd(); + if (mode === "docs") return runDocsCheck({ env }); + if (mode === "readmes" || mode === "readme") return runReadmeCheck({ repoRoot }); + emitError(null, `Unknown mode "${mode}". Use "readmes" or "docs".`); + return 2; +} + +if (require.main === module) { + process.exit(main()); +} + +module.exports = { + README_FILES, + DOCS_EN_PREFIX, + DOCS_LOCALES, + extractHeadings, + level2Headings, + structuralSignature, + sameSequence, + firstDiffIndex, + compareReadmeStructures, + counterpartPaths, + findMissingTranslations, + getChangedFiles, + splitList, + runReadmeCheck, + runDocsCheck, + main, +}; diff --git a/scripts/github-actions/check-translation-sync.test.js b/scripts/github-actions/check-translation-sync.test.js new file mode 100644 index 00000000..7a2379dc --- /dev/null +++ b/scripts/github-actions/check-translation-sync.test.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +"use strict"; + +// Unit tests for scripts/github-actions/check-translation-sync.js. +// +// Run via: node scripts/github-actions/check-translation-sync.test.js +// (also wired as `npm run test:github-actions`). +// +// Plain Node + assert, no external deps and no `node --test`, mirroring +// post-review-comments.test.js so both run on node >= 14. + +const assert = require("assert"); +const path = require("path"); +const { + extractHeadings, + level2Headings, + structuralSignature, + compareReadmeStructures, + counterpartPaths, + findMissingTranslations, + getChangedFiles, + splitList, +} = require(path.join(__dirname, "check-translation-sync.js")); + +// A minimal but realistic README skeleton: an intro H1, three `##` sections, +// one of them with a `###` subheading, plus a fenced bash block whose `#` +// comment lines must NOT be parsed as headings. +function readme(headings) { + // headings: array of "## Text" / "### Text" / "# Text" lines already formed. + return [ + "# Project", + "", + "Intro paragraph.", + "", + "```bash", + "# this is a shell comment, not a heading", + "## also not a heading", + "echo hi", + "```", + "", + ...headings.flatMap((h) => [h, "", "body text", ""]), + ].join("\n"); +} + +function testExtractHeadingsSkipsCodeFences() { + const md = readme(["## Alpha", "### Alpha One", "## Beta"]); + const headings = extractHeadings(md); + // Only the real headings: H1 + 2x H2 + 1x H3. The fenced `#`/`##` lines are ignored. + assert.deepStrictEqual( + headings.map((h) => `${h.level}:${h.text}`), + ["1:Project", "2:Alpha", "3:Alpha One", "2:Beta"] + ); + assert.deepStrictEqual(level2Headings(md), ["Alpha", "Beta"]); + assert.deepStrictEqual(structuralSignature(md), [1, 2, 3, 2]); +} + +function testExtractHeadingsTildeFenceAndTrailingHashes() { + const md = ["# T", "~~~", "## fenced", "~~~", "## Real ##"].join("\n"); + assert.deepStrictEqual( + extractHeadings(md).map((h) => `${h.level}:${h.text}`), + ["1:T", "2:Real"] // closing `## Real ##` trailing hashes stripped + ); +} + +function testExtractHeadingsInnerShorterFenceDoesNotClose() { + // CommonMark: a closing fence must be at least as long as the opening one. + // A block opened with ```` (4 backticks) must NOT be closed by an inner ``` + // (3 backticks); everything up to the matching 4-backtick line stays code. + const md = [ + "# Title", + "````", // open, length 4 + "## not a heading (inside code)", + "```", // shorter than opening -> does NOT close + "## also inside code", + "````", // length >= 4 -> real close + "## Real Heading", + ].join("\n"); + assert.deepStrictEqual( + extractHeadings(md).map((h) => `${h.level}:${h.text}`), + ["1:Title", "2:Real Heading"] + ); +} + +// --- README structure comparison ------------------------------------------- + +function testIdenticalStructurePasses() { + // Same outline, DIFFERENT heading text (simulating translations). Must pass: + // the check compares structure, not text. + const en = readme(["## What is it?", "### Details", "## Usage"]); + const zh = readme(["## 这是什么?", "### 细节", "## 使用方法"]); + const ja = readme(["## これは何ですか?", "### 詳細", "## 使い方"]); + const { ok, errors } = compareReadmeStructures([ + { name: "README.md", content: en }, + { name: "README.zh-CN.md", content: zh }, + { name: "README.ja-JP.md", content: ja }, + ]); + assert.strictEqual(ok, true, JSON.stringify(errors)); + assert.strictEqual(errors.length, 0); +} + +function testAddedHeadingFails() { + const en = readme(["## Alpha", "## Beta"]); + const zh = readme(["## Alpha", "## Beta", "## Gamma"]); // extra ## + const { ok, errors } = compareReadmeStructures([ + { name: "README.md", content: en }, + { name: "README.zh-CN.md", content: zh }, + ]); + assert.strictEqual(ok, false); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].file, "README.zh-CN.md"); + assert.match(errors[0].message, /level-2 \(##\) heading/); + assert.match(errors[0].message, /has 3 level-2 .* has 2/); +} + +function testDroppedHeadingFails() { + const en = readme(["## Alpha", "## Beta", "## Gamma"]); + const ja = readme(["## Alpha", "## Gamma"]); // dropped Beta + const { ok, errors } = compareReadmeStructures([ + { name: "README.md", content: en }, + { name: "README.ja-JP.md", content: ja }, + ]); + assert.strictEqual(ok, false); + assert.strictEqual(errors[0].file, "README.ja-JP.md"); + assert.match(errors[0].message, /has 2 level-2 .* has 3/); +} + +function testReorderedHeadingFails() { + // Two `##` sections with different sub-structure; swapping them keeps the + // level-2 COUNT identical but changes the outline order -> must fail. + // The sub-structure must differ so the reorder is detectable structurally: + // same level-2 count, different heading-level outline. + const en2 = readme(["## Alpha", "### A1", "## Beta"]); // [1,2,3,2] + const zh2 = readme(["## Beta", "## Alpha", "### A1"]); // [1,2,2,3] + const { ok, errors } = compareReadmeStructures([ + { name: "README.md", content: en2 }, + { name: "README.zh-CN.md", content: zh2 }, + ]); + assert.strictEqual(ok, false); + assert.strictEqual(errors[0].file, "README.zh-CN.md"); + assert.match(errors[0].message, /reordered or its level changed/); + assert.match(errors[0].message, /heading #3/); // first divergence position +} + +function testMissingReferenceOnlyIsFine() { + // A single file (only the reference) cannot diverge. + const { ok } = compareReadmeStructures([ + { name: "README.md", content: readme(["## A"]) }, + ]); + assert.strictEqual(ok, true); +} + +// --- docs en -> zh/ja counterpart sync ------------------------------------- + +function testCounterpartPaths() { + assert.deepStrictEqual( + counterpartPaths("pages/src/content/docs/en/integrations/ci.md"), + [ + { locale: "zh", path: "pages/src/content/docs/zh/integrations/ci.md" }, + { locale: "ja", path: "pages/src/content/docs/ja/integrations/ci.md" }, + ] + ); +} + +function testEnOnlyChangeWarns() { + const changed = [ + "pages/src/content/docs/en/faq.md", + "internal/agent/agent.go", + ]; + const warnings = findMissingTranslations(changed); + // Both zh and ja counterparts missing -> two warnings. + assert.strictEqual(warnings.length, 2); + assert.deepStrictEqual( + warnings.map((w) => w.counterpart).sort(), + [ + "pages/src/content/docs/ja/faq.md", + "pages/src/content/docs/zh/faq.md", + ] + ); +} + +function testEnWithBothCounterpartsNoWarn() { + const changed = [ + "pages/src/content/docs/en/faq.md", + "pages/src/content/docs/zh/faq.md", + "pages/src/content/docs/ja/faq.md", + ]; + assert.deepStrictEqual(findMissingTranslations(changed), []); +} + +function testEnWithOnlyOneCounterpartWarnsForTheOther() { + const changed = [ + "pages/src/content/docs/en/mcp.md", + "pages/src/content/docs/zh/mcp.md", // ja still missing + ]; + const warnings = findMissingTranslations(changed); + assert.strictEqual(warnings.length, 1); + assert.strictEqual(warnings[0].locale, "ja"); + assert.strictEqual(warnings[0].counterpart, "pages/src/content/docs/ja/mcp.md"); +} + +function testNonDocsAndNonEnChangesIgnored() { + const changed = [ + "pages/src/content/docs/zh/faq.md", // zh-only change: not our trigger + "README.md", + "pages/src/content/docs/en/logo.png", // en but not markdown + ]; + assert.deepStrictEqual(findMissingTranslations(changed), []); +} + +// --- changed-files resolution ---------------------------------------------- + +function testGetChangedFilesFromExplicitEnv() { + const env = { OCR_CHANGED_FILES: "a.md\npages/src/content/docs/en/x.md\n" }; + assert.deepStrictEqual(getChangedFiles(env), [ + "a.md", + "pages/src/content/docs/en/x.md", + ]); +} + +function testGetChangedFilesFromGitDiff() { + const calls = []; + const fakeExec = (cmd, args) => { + calls.push({ cmd, args }); + return "pages/src/content/docs/en/faq.md\ninternal/x.go\n"; + }; + const env = { OCR_BASE_SHA: "base", OCR_HEAD_SHA: "head" }; + const out = getChangedFiles(env, fakeExec); + assert.deepStrictEqual(out, [ + "pages/src/content/docs/en/faq.md", + "internal/x.go", + ]); + assert.strictEqual(calls[0].cmd, "git"); + assert.deepStrictEqual(calls[0].args, ["diff", "--name-only", "base...head"]); +} + +function testGetChangedFilesGitFailureIsNonFatal() { + const env = { OCR_BASE_SHA: "base", OCR_HEAD_SHA: "head" }; + const boom = () => { + throw new Error("no merge base"); + }; + // Silence the expected ::warning annotation this path emits. + const orig = console.log; + console.log = () => {}; + try { + assert.deepStrictEqual(getChangedFiles(env, boom), []); + } finally { + console.log = orig; + } +} + +function testGetChangedFilesEmptyWhenNoInputs() { + assert.deepStrictEqual(getChangedFiles({}), []); +} + +function testSplitList() { + assert.deepStrictEqual(splitList("a\nb,c\n\n d "), ["a", "b", "c", "d"]); +} + +function main() { + testExtractHeadingsSkipsCodeFences(); + testExtractHeadingsTildeFenceAndTrailingHashes(); + testExtractHeadingsInnerShorterFenceDoesNotClose(); + testIdenticalStructurePasses(); + testAddedHeadingFails(); + testDroppedHeadingFails(); + testReorderedHeadingFails(); + testMissingReferenceOnlyIsFine(); + testCounterpartPaths(); + testEnOnlyChangeWarns(); + testEnWithBothCounterpartsNoWarn(); + testEnWithOnlyOneCounterpartWarnsForTheOther(); + testNonDocsAndNonEnChangesIgnored(); + testGetChangedFilesFromExplicitEnv(); + testGetChangedFilesFromGitDiff(); + testGetChangedFilesGitFailureIsNonFatal(); + testGetChangedFilesEmptyWhenNoInputs(); + testSplitList(); + console.log("All check-translation-sync tests passed."); +} + +try { + main(); +} catch (err) { + console.error(err); + process.exit(1); +}