From 3daad86db8cbc453f365d6e5db0dfb6d055ef0d3 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sun, 6 Sep 2026 21:18:48 -0300 Subject: [PATCH] feat(i18n): wire audit-translations into pnpm run verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the .agents/scripts/audit-translations.mjs script into the existing pnpm run verify chain as the first gate. A `pt` field identical to its `en` counterpart is how a bilingual site quietly becomes monolingual — this script catches that before any other check runs. Added an allowlist mechanism: the script now reads a sibling `.agents/scripts/audit-translations.allowlist.json` file. Entries in the allowlist are listed in the output under "ALLOWED" and do not fail the gate. Currently 18 entries: numeric card labels ("01"–"06") in chapters/landing.json and chapters/summary.json, the "Skills" product noun on both, the "Brief" handoff step label in chapters/agents.json, and the three model-tier series names in providers/{claude,gemini,openai}.json. Adding to the allowlist requires a deliberate edit + commit; future translators can see the allowlist and understand which identicals are intentional. The verify chain order is now: 1. audit-translations.mjs — fail-fast on translation regressions 2. verify.mjs — content + interaction contracts 3. audit-ui.mjs — responsive / no-external-dep audit 4. check-tokens.mjs — design-token enforcement Ownership notes: - package.json is owned by the astro-architect agent per .agents/rules/git-worktrees.md. The wiring in this commit is the change the user explicitly asked for; the architect should review the format on merge. - scripts/verify.mjs is owned by the verification-engineer agent per the same table. The pre-existing assertion that `package.json` contains the literal verify-script string no longer matches once `audit-translations.mjs &&` is prepended. This commit updates the assertion from a strict `.includes()` substring check to a regex that allows the optional translation-audit prefix while still requiring the three core scripts (verify.mjs, audit-ui.mjs, check-tokens.mjs) to run in order. The regex still rejects any chain that drops one of them. Verified by running pnpm run verify from the worktree — all four checks pass with the translations from the prior five commits. --- .../scripts/audit-translations.allowlist.json | 112 +++++++++++++++ .agents/scripts/audit-translations.mjs | 127 +++++++++++++----- package.json | 2 +- scripts/verify.mjs | 12 +- 4 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 .agents/scripts/audit-translations.allowlist.json 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');