diff --git a/.agents/scripts/audit-translations.allowlist.json b/.agents/scripts/audit-translations.allowlist.json new file mode 100644 index 0000000..49bef57 --- /dev/null +++ b/.agents/scripts/audit-translations.allowlist.json @@ -0,0 +1,112 @@ +{ + "entries": [ + { + "collection": "chapters", + "file": "agents.json", + "field": "sections.1.steps.0.label", + "reason": "Handoff step label (\"Brief\"); kept English by glossary convention." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.0.label", + "reason": "Numeric card label (\"01\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.1.label", + "reason": "Numeric card label (\"02\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.2.label", + "reason": "Numeric card label (\"03\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.2.title", + "reason": "Product noun (\"Skills\"); kept English by glossary convention." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.3.label", + "reason": "Numeric card label (\"04\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.4.label", + "reason": "Numeric card label (\"05\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "landing.json", + "field": "cards.5.label", + "reason": "Numeric card label (\"06\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.0.label", + "reason": "Numeric card label (\"01\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.1.label", + "reason": "Numeric card label (\"02\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.2.label", + "reason": "Numeric card label (\"03\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.2.title", + "reason": "Product noun (\"Skills\"); kept English by glossary convention." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.3.label", + "reason": "Numeric card label (\"04\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.4.label", + "reason": "Numeric card label (\"05\"); identical across locales by design." + }, + { + "collection": "chapters", + "file": "summary.json", + "field": "cards.5.label", + "reason": "Numeric card label (\"06\"); identical across locales by design." + }, + { + "collection": "providers", + "file": "claude.json", + "field": "title", + "reason": "Model-tier series name (\"Opus · Sonnet · Haiku\"); kept English as product name." + }, + { + "collection": "providers", + "file": "gemini.json", + "field": "title", + "reason": "Model-tier series name (\"Pro · Flash · Flash-Lite\"); kept English as product name." + }, + { + "collection": "providers", + "file": "openai.json", + "field": "title", + "reason": "Model-tier series name (\"Sol · Terra · Luna\"); kept English as product name." + } + ] +} diff --git a/.agents/scripts/audit-translations.mjs b/.agents/scripts/audit-translations.mjs index b663056..bcad668 100644 --- a/.agents/scripts/audit-translations.mjs +++ b/.agents/scripts/audit-translations.mjs @@ -7,16 +7,25 @@ // node .agents/scripts/audit-translations.mjs # walks src/content // node .agents/scripts/audit-translations.mjs src/content/ # explicit root // -// Exits non-zero if any pair is identical. The output is grouped by -// collection, then by file, then by field path, so the report reads like +// 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 { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +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'; @@ -69,9 +78,27 @@ const collectionOf = (file, root) => { 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 totalIdentical = 0; +let totalReal = 0; +let totalAllowed = 0; for (const file of files) { let data; @@ -84,40 +111,78 @@ for (const file of files) { const pairs = collect(data); if (pairs.length === 0) continue; - const offenders = pairs.filter(([, en, pt]) => same(en, pt)); - if (offenders.length === 0) continue; - - totalLocalized += pairs.length; - totalIdentical += offenders.length; - 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; - if (!groups.has(collection)) groups.set(collection, []); - groups.get(collection).push({ file: filename, pairs: offenders }); + // 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 }); + } } -if (groups.size === 0) { +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); } -// Print grouped report. -for (const [collection, entries] of groups) { - console.log(`\n[${collection}]`); - for (const { file, pairs } of entries) { - console.log(` ${file}`); - for (const [field, en, pt] of pairs) { - const sample = typeof en === 'string' ? JSON.stringify(en).slice(0, 80) : ''; - console.log(` - ${field.padEnd(28)} ${sample}`); - } - } +// 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( - `\nFAIL: ${totalIdentical} 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); diff --git a/package.json b/package.json index 73ea234..12ce04f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "check": "astro check", "lint": "eslint . --max-warnings=0 && stylelint --allow-empty-input 'src/**/*.css' --max-warnings=0", "format": "prettier --write .", - "verify": "node scripts/verify.mjs && node scripts/audit-ui.mjs && node .agents/scripts/check-tokens.mjs", + "verify": "node .agents/scripts/audit-translations.mjs && node scripts/verify.mjs && node scripts/audit-ui.mjs && node .agents/scripts/check-tokens.mjs", "gate": "./.agents/scripts/gate.sh", "snapshot": "node .agents/scripts/snapshot-route.mjs", "prepare": "husky" diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 8cda785..662e66d 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -271,11 +271,13 @@ if (!allPages.every(([, page]) => page.includes('name="viewport"'))) throw new Error('a built route lacks a viewport declaration'); if (allPages.some(([, page]) => /<(script|link)[^>]+(src|href)="https?:[^\"]+"/i.test(page))) throw new Error('a built route has an external runtime dependency'); -if ( - !read('package.json').includes( - '"verify": "node scripts/verify.mjs && node scripts/audit-ui.mjs && node .agents/scripts/check-tokens.mjs"', - ) -) +// `pnpm run verify` may now prepend `node .agents/scripts/audit-translations.mjs &&` +// for fail-fast translation checks. Allow an optional audit-translations prefix +// while still requiring the three core scripts in order. +const verifyChain = read('package.json'); +const verifyShape = + /\"verify\":\s*\"(?:node \.agents\/scripts\/audit-translations\.mjs && )?node scripts\/verify\.mjs && node scripts\/audit-ui\.mjs && node \.agents\/scripts\/check-tokens\.mjs\"/; +if (!verifyShape.test(verifyChain)) throw new Error('pnpm verify no longer runs audit-ui and check-tokens'); if (!read('.agents/scripts/gate.sh').includes('pnpm run verify')) throw new Error('the gate no longer runs the output contract');