162 lines
5.3 KiB
JavaScript
Executable File
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);
|
|
});
|
|
}
|