Files
2026-07-13 13:39:12 +08:00

162 lines
5.3 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* OmniRoute — i18n translation drift checker (CI gate).
*
* Verifies that every source file recorded in `.i18n-state.json` still has
* the same SHA-256 hash on disk and that every produced translation file
* exists with the recorded target hash. Does NOT call any API — purely a
* deterministic state-vs-disk comparison.
*
* Modes:
* --strict (default) exit 1 on any drift, print the offending paths
* --warn exit 0, print warnings only
* --json emit a JSON report to stdout (no human log lines)
*
* Recommended usage in CI:
* npm run i18n:check
*/
import { promises as fs, existsSync } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const STATE_PATH = path.join(ROOT, ".i18n-state.json");
function parseArgs(argv) {
const opts = { mode: "strict", json: false };
for (const arg of argv.slice(2)) {
if (arg === "--warn") opts.mode = "warn";
else if (arg === "--strict") opts.mode = "strict";
else if (arg === "--json") opts.json = true;
else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/i18n/check-translation-drift.mjs [--strict|--warn] [--json]",
"",
" --strict (default) exit 1 when any source or target is out of date",
" --warn report drift but exit 0",
" --json write a machine-readable report to stdout",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
async function main() {
const opts = parseArgs(process.argv);
if (!existsSync(STATE_PATH)) {
const msg = ".i18n-state.json not found — run `npm run i18n:run` to bootstrap.";
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: false, reason: "missing-state" }) + "\n");
} else {
console.error(`[i18n-check] ${msg}`);
}
process.exit(opts.mode === "warn" ? 0 : 1);
}
const state = JSON.parse(await fs.readFile(STATE_PATH, "utf8"));
const sources = state.sources || {};
const driftedSources = [];
const missingTargets = [];
const driftedTargets = [];
let checkedSources = 0;
let checkedTargets = 0;
for (const [rel, entry] of Object.entries(sources)) {
checkedSources++;
const absSource = path.join(ROOT, rel);
if (!existsSync(absSource)) {
driftedSources.push({ rel, reason: "source-missing" });
continue;
}
const currentHash = sha256(await fs.readFile(absSource));
if (currentHash !== entry.source_hash) {
driftedSources.push({
rel,
reason: "source-changed",
recorded: entry.source_hash,
current: currentHash,
});
}
for (const [locale, info] of Object.entries(entry.locales || {})) {
checkedTargets++;
// Mirror the path layout used by run-translation.mjs.
const targetAbs = rel.includes("/")
? path.join(ROOT, "docs", "i18n", locale, rel)
: path.join(ROOT, "docs", "i18n", locale, rel);
if (!existsSync(targetAbs)) {
missingTargets.push({ rel, locale });
continue;
}
const targetHash = sha256(await fs.readFile(targetAbs));
if (info.target_hash && targetHash !== info.target_hash) {
driftedTargets.push({ rel, locale, recorded: info.target_hash, current: targetHash });
}
}
}
const ok =
driftedSources.length === 0 && missingTargets.length === 0 && driftedTargets.length === 0;
if (opts.json) {
process.stdout.write(
JSON.stringify(
{
ok,
checkedSources,
checkedTargets,
driftedSources,
missingTargets,
driftedTargets,
},
null,
2
) + "\n"
);
} else {
console.log(`[i18n-check] checked sources=${checkedSources}, targets=${checkedTargets}`);
if (driftedSources.length) {
console.log(`[i18n-check] drifted sources (${driftedSources.length}):`);
for (const d of driftedSources) console.log(` - ${d.rel} (${d.reason})`);
}
if (missingTargets.length) {
console.log(`[i18n-check] missing targets (${missingTargets.length}):`);
for (const m of missingTargets) console.log(` - ${m.rel} [${m.locale}]`);
}
if (driftedTargets.length) {
console.log(`[i18n-check] drifted targets (${driftedTargets.length}):`);
for (const t of driftedTargets) console.log(` - ${t.rel} [${t.locale}]`);
}
if (ok) {
console.log("[i18n-check] PASS — all sources and targets match recorded hashes.");
} else if (opts.mode === "warn") {
console.log("[i18n-check] WARN — drift detected (warn mode, exiting 0).");
} else {
console.log("[i18n-check] FAIL — drift detected. Run `npm run i18n:run` to refresh.");
}
}
if (!ok && opts.mode !== "warn") process.exit(1);
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((err) => {
console.error("[i18n-check] ERROR", err?.stack || err?.message || String(err));
process.exit(1);
});
}