#!/usr/bin/env node // Walk every entry in src/content/** and flag any localized field whose // `en` and `pt` values are identical. Identical pairs are how a bilingual // site quietly becomes monolingual — the schema accepts them, the build // passes, and a Portuguese speaker sees English. // // node .agents/scripts/audit-translations.mjs # walks src/content // node .agents/scripts/audit-translations.mjs src/content/ # explicit root // // Exits non-zero if any unallowed pair is identical. The output is grouped // by collection, then by file, then by field path, so the report reads like // a translation backlog rather than a wall of strings. // // This is the sibling of extract-strings.mjs: that one proves the // migration moved every string; this one proves every string actually // differs across locales. // // Allowlist: a sibling `.agents/scripts/audit-translations.allowlist.json` // lists (collection, file, field) tuples that are intentionally identical // across locales (numeric card labels, product nouns, model-tier series // names, etc.). Entries in the allowlist are listed under "ALLOWED" in the // output and do not affect the exit code. Adding to the allowlist is a // deliberate edit + commit; future translators can see it and understand // which identicals are intentional. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = process.argv[2] ?? 'src/content'; const walk = (dir) => readdirSync(dir).flatMap((name) => { const path = join(dir, name); return statSync(path).isDirectory() ? walk(path) : [path]; }); const files = walk(ROOT).filter((path) => path.endsWith('.json')); // Returns an array of [fieldPath, en, pt] tuples for a parsed document. // Handles the two shapes in the project: // 1. { ..., someKey: { en, pt }, ... } — the `localized` Zod helper // 2. { en: { ...string IDs... }, pt: { ...string IDs... } } — rules/copy.json const collect = (node, fieldPath = '', out = []) => { if (!node || typeof node !== 'object') return out; if ('en' in node && 'pt' in node) { out.push([fieldPath || '(root)', node.en, node.pt]); return out; } for (const [key, value] of Object.entries(node)) { const next = fieldPath ? `${fieldPath}.${key}` : key; // Top-level `{ en: {...}, pt: {...} }` — recurse into each side. if ( (key === 'en' || key === 'pt') && value && typeof value === 'object' && !Array.isArray(value) ) { collect(value, '', out); } else { collect(value, next, out); } } return out; }; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); // Compute the collection name from the path relative to ROOT. // src/content/chapters/landing.json -> chapters // src/content/rules/copy.json -> rules const collectionOf = (file, root) => { const rel = relative(root, file); const parts = rel.split('/'); // ['chapters', 'landing.json'] -> 'chapters' return parts.length >= 2 ? parts[0] : '(root)'; }; // Load the allowlist. The file lives next to this script and lists // intentional identicals (numeric labels, product nouns, model-tier series // names, etc.). Missing file is OK — the audit still runs. const here = dirname(fileURLToPath(import.meta.url)); const allowlistPath = join(here, 'audit-translations.allowlist.json'); const allowlist = new Set(); const allowlistReasons = new Map(); if (existsSync(allowlistPath)) { const { entries } = JSON.parse(readFileSync(allowlistPath, 'utf8')); for (const { collection, file, field, reason } of entries) { allowlist.add(`${collection}${file}${field}`); allowlistReasons.set(`${collection}${file}${field}`, reason); } } const groups = new Map(); const allowedGroups = new Map(); let totalLocalized = 0; let totalReal = 0; let totalAllowed = 0; for (const file of files) { let data; try { data = JSON.parse(readFileSync(file, 'utf8')); } catch (err) { console.error(`skip: ${file} is not valid JSON (${err.message})`); continue; } const pairs = collect(data); if (pairs.length === 0) continue; const collection = collectionOf(file, ROOT); const filename = relative(ROOT, file); // Allowlist entries key on the file's name within its collection // (e.g. `landing.json`), so strip the collection prefix from // `filename` when matching. The full relative path is still used // for the report display. const collectionFile = filename.startsWith(collection + '/') ? filename.slice(collection.length + 1) : filename; // Split identical pairs into real offenders (fail the gate) and // allowlisted ones (listed under ALLOWED, do not affect exit code). const real = []; const allowed = []; for (const [field, en, pt] of pairs) { if (!same(en, pt)) continue; const key = `${collection}${collectionFile}${field}`; if (allowlist.has(key)) { allowed.push([field, en, allowlistReasons.get(key)]); } else { real.push([field, en, null]); } } totalLocalized += pairs.length; if (real.length > 0) { totalReal += real.length; if (!groups.has(collection)) groups.set(collection, []); groups.get(collection).push({ file: filename, pairs: real }); } if (allowed.length > 0) { totalAllowed += allowed.length; if (!allowedGroups.has(collection)) allowedGroups.set(collection, []); allowedGroups.get(collection).push({ file: filename, pairs: allowed }); } } const printGroup = (entries, label) => { for (const [collection, list] of entries) { console.log(`\n[${collection}]`); for (const { file, pairs } of list) { console.log(` ${file}`); for (const [field, en, reason] of pairs) { const sample = typeof en === 'string' ? JSON.stringify(en).slice(0, 80) : ''; const suffix = reason ? ` [${label}: ${reason}]` : ''; console.log(` - ${field.padEnd(28)} ${sample}${suffix}`); } } } }; // Clean case: no identicals at all. if (totalReal === 0 && totalAllowed === 0) { console.log('OK: no identical en/pt pairs found under', ROOT); process.exit(0); } // Show allowed entries first, then real offenders (if any). printGroup(allowedGroups, 'ALLOWED'); if (totalReal > 0) { printGroup(groups, 'REAL'); console.log( `\nFAIL: ${totalReal} unallowed identical localized field(s) across ${groups.size} collection(s) of ${ROOT}`, ); console.log( 'A `pt` identical to `en` is how a bilingual site becomes monolingual. Translate, then re-run.', ); process.exit(1); } console.log(`\nOK: ${totalAllowed} identical field(s) found, all on the allowlist.`); process.exit(0);