feat(i18n): wire audit-translations into pnpm run verify
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.
This commit is contained in:
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,16 +7,25 @@
|
|||||||
// node .agents/scripts/audit-translations.mjs # walks src/content
|
// node .agents/scripts/audit-translations.mjs # walks src/content
|
||||||
// node .agents/scripts/audit-translations.mjs src/content/ # explicit root
|
// node .agents/scripts/audit-translations.mjs src/content/ # explicit root
|
||||||
//
|
//
|
||||||
// Exits non-zero if any pair is identical. The output is grouped by
|
// Exits non-zero if any unallowed pair is identical. The output is grouped
|
||||||
// collection, then by file, then by field path, so the report reads like
|
// by collection, then by file, then by field path, so the report reads like
|
||||||
// a translation backlog rather than a wall of strings.
|
// a translation backlog rather than a wall of strings.
|
||||||
//
|
//
|
||||||
// This is the sibling of extract-strings.mjs: that one proves the
|
// This is the sibling of extract-strings.mjs: that one proves the
|
||||||
// migration moved every string; this one proves every string actually
|
// migration moved every string; this one proves every string actually
|
||||||
// differs across locales.
|
// 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 { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const ROOT = process.argv[2] ?? 'src/content';
|
const ROOT = process.argv[2] ?? 'src/content';
|
||||||
|
|
||||||
@@ -69,9 +78,27 @@ const collectionOf = (file, root) => {
|
|||||||
return parts.length >= 2 ? parts[0] : '(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 groups = new Map();
|
||||||
|
const allowedGroups = new Map();
|
||||||
let totalLocalized = 0;
|
let totalLocalized = 0;
|
||||||
let totalIdentical = 0;
|
let totalReal = 0;
|
||||||
|
let totalAllowed = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
let data;
|
let data;
|
||||||
@@ -84,40 +111,78 @@ for (const file of files) {
|
|||||||
const pairs = collect(data);
|
const pairs = collect(data);
|
||||||
if (pairs.length === 0) continue;
|
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 collection = collectionOf(file, ROOT);
|
||||||
const filename = relative(ROOT, file);
|
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, []);
|
// Split identical pairs into real offenders (fail the gate) and
|
||||||
groups.get(collection).push({ file: filename, pairs: offenders });
|
// 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) : '<non-string>';
|
||||||
|
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);
|
console.log('OK: no identical en/pt pairs found under', ROOT);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print grouped report.
|
// Show allowed entries first, then real offenders (if any).
|
||||||
for (const [collection, entries] of groups) {
|
printGroup(allowedGroups, 'ALLOWED');
|
||||||
console.log(`\n[${collection}]`);
|
|
||||||
for (const { file, pairs } of entries) {
|
if (totalReal > 0) {
|
||||||
console.log(` ${file}`);
|
printGroup(groups, 'REAL');
|
||||||
for (const [field, en, pt] of pairs) {
|
console.log(
|
||||||
const sample = typeof en === 'string' ? JSON.stringify(en).slice(0, 80) : '<non-string>';
|
`\nFAIL: ${totalReal} unallowed identical localized field(s) across ${groups.size} collection(s) of ${ROOT}`,
|
||||||
console.log(` - ${field.padEnd(28)} ${sample}`);
|
);
|
||||||
}
|
console.log(
|
||||||
}
|
'A `pt` identical to `en` is how a bilingual site becomes monolingual. Translate, then re-run.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`\nOK: ${totalAllowed} identical field(s) found, all on the allowlist.`);
|
||||||
`\nFAIL: ${totalIdentical} identical localized field(s) across ${groups.size} collection(s) of ${ROOT}`,
|
process.exit(0);
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'A `pt` identical to `en` is how a bilingual site becomes monolingual. Translate, then re-run.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"lint": "eslint . --max-warnings=0 && stylelint --allow-empty-input 'src/**/*.css' --max-warnings=0",
|
"lint": "eslint . --max-warnings=0 && stylelint --allow-empty-input 'src/**/*.css' --max-warnings=0",
|
||||||
"format": "prettier --write .",
|
"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",
|
"gate": "./.agents/scripts/gate.sh",
|
||||||
"snapshot": "node .agents/scripts/snapshot-route.mjs",
|
"snapshot": "node .agents/scripts/snapshot-route.mjs",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
+7
-5
@@ -271,11 +271,13 @@ if (!allPages.every(([, page]) => page.includes('name="viewport"')))
|
|||||||
throw new Error('a built route lacks a viewport declaration');
|
throw new Error('a built route lacks a viewport declaration');
|
||||||
if (allPages.some(([, page]) => /<(script|link)[^>]+(src|href)="https?:[^\"]+"/i.test(page)))
|
if (allPages.some(([, page]) => /<(script|link)[^>]+(src|href)="https?:[^\"]+"/i.test(page)))
|
||||||
throw new Error('a built route has an external runtime dependency');
|
throw new Error('a built route has an external runtime dependency');
|
||||||
if (
|
// `pnpm run verify` may now prepend `node .agents/scripts/audit-translations.mjs &&`
|
||||||
!read('package.json').includes(
|
// for fail-fast translation checks. Allow an optional audit-translations prefix
|
||||||
'"verify": "node scripts/verify.mjs && node scripts/audit-ui.mjs && node .agents/scripts/check-tokens.mjs"',
|
// 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');
|
throw new Error('pnpm verify no longer runs audit-ui and check-tokens');
|
||||||
if (!read('.agents/scripts/gate.sh').includes('pnpm run verify'))
|
if (!read('.agents/scripts/gate.sh').includes('pnpm run verify'))
|
||||||
throw new Error('the gate no longer runs the output contract');
|
throw new Error('the gate no longer runs the output contract');
|
||||||
|
|||||||
Reference in New Issue
Block a user