Files
ai-for-dummies/scripts/verify.mjs
T
Marcos Paulo 3daad86db8 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.
2026-09-06 21:18:48 -03:00

487 lines
19 KiB
JavaScript

import { readFileSync, readdirSync } from 'node:fs';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const built = (path) => read(`dist/${path}`);
const rendered = (value) =>
value
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&')
.replace(
/&(lt|gt|quot|nbsp);/g,
(_, entity) => ({ lt: '<', gt: '>', quot: '"', nbsp: ' ' })[entity],
)
.replace(/&#0?39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
const normalize = (value) => value.replace(/\s+/g, ' ').trim();
const html = {
landing: built('index.html'),
guide: built('full-guide/index.html'),
summary: built('summary/index.html'),
models: built('models/index.html'),
agents: built('agents/index.html'),
skills: built('skills/index.html'),
rules: built('rules/index.html'),
review: built('skills-review/index.html'),
starter: built('hands-on/starter/index.html'),
labRules: built('hands-on/rules/index.html'),
};
const snapshot = (path) => normalize(read(`.agents/snapshots/${path}`));
const reviewAssetSrc = html.review.match(/src="([^"]+\.[a-zA-Z0-9_-]{8,}\.js)"/)?.[1];
const reviewAsset = reviewAssetSrc ? read(`dist/_astro/${reviewAssetSrc.split('/').at(-1)}`) : '';
const builtCss = readdirSync(new URL('../dist/_astro/', import.meta.url))
.filter((file) => file.endsWith('.css'))
.map((file) => read(`dist/_astro/${file}`))
.join('\n');
const source = {
references: read('docs/references/README.md'),
additionalReading: read('docs/references/additional-reading.md'),
skillSources: read('src/content/skillSources/ponytail.json'),
providers: [
read('src/content/providers/openai.json'),
read('src/content/providers/claude.json'),
read('src/content/providers/gemini.json'),
].join('\n'),
starter: read('public/hands-on/starter/app.js'),
ndoReview: read('src/content/reviews/ndo-repro.md'),
};
// Rendered prose catches a deleted paragraph that string-token checks miss.
if (rendered(html.landing) !== snapshot('index.txt'))
throw new Error('landing rendered-text snapshot changed');
if (rendered(html.guide) !== snapshot('full-guide.txt'))
throw new Error('full-guide rendered-text snapshot changed');
if (rendered(html.summary) !== snapshot('summary.txt'))
throw new Error('summary rendered-text snapshot changed');
if (rendered(html.models) !== snapshot('models.txt'))
throw new Error('models rendered-text snapshot changed');
if (rendered(html.agents) !== snapshot('agents.txt'))
throw new Error('agents rendered-text snapshot changed');
if (rendered(html.skills) !== snapshot('skills.txt'))
throw new Error('skills rendered-text snapshot changed');
if (rendered(html.rules) !== snapshot('rules.txt'))
throw new Error('rules rendered-text snapshot changed');
if (rendered(html.review) !== snapshot('skills-review.txt'))
throw new Error('skills-review rendered-text snapshot changed');
if (rendered(html.starter) !== snapshot('hands-on/starter.txt'))
throw new Error('starter rendered-text snapshot changed');
if (rendered(html.labRules) !== snapshot('hands-on/rules.txt'))
throw new Error('rules lab rendered-text snapshot changed');
// The 102 Portuguese strings were extracted verbatim from the legacy
// `app.js` `translations.pt` object at cutover, before that file was deleted.
// This is a legacy capture, not a snapshot of the Astro build: it still asserts
// against an independent source, which is the whole point of the check.
const portuguese = JSON.parse(read('.agents/snapshots/full-guide-pt.json')).map(rendered);
if (portuguese.length !== 102)
throw new Error('full-guide Portuguese snapshot no longer has 102 translated strings');
// Without this, trimming the snapshot would make the check below pass vacuously.
if (portuguese.some((value) => !value.trim()))
throw new Error('full-guide Portuguese snapshot has an empty entry');
if (!portuguese.every((value) => rendered(html.guide).includes(value)))
throw new Error('built full-guide lost a Portuguese translation');
const has = (page, tokens) => tokens.every((token) => page.includes(token));
if (!has(html.guide, ['data-phase="plan"', 'data-phase="build"', 'data-phase="review"']))
throw new Error('built full-guide lost its phase selector');
if (!has(html.guide, ['data-tree="main"', 'data-tree="ui"', 'data-worker="ui"']))
throw new Error('built full-guide lost its worker or worktree selector');
if (
!has(html.guide, [
'data-route="plan"',
'data-model-provider="openai"',
'data-model-provider="claude"',
'data-model-provider="gemini"',
])
)
throw new Error('built full-guide lost its model-routing controls');
if (!has(html.guide, ['data-effort="low"', 'data-effort="medium"', 'data-effort="high"']))
throw new Error('built full-guide lost its reasoning-effort controls');
if (
!has(html.guide, [
'data-skill-file="skill"',
'data-skill-step="observe"',
'data-skill-step="validate"',
])
)
throw new Error('built full-guide lost its skill anatomy controls');
if (
!has(html.guide, [
'data-common-skill="ponytail"',
'data-common-skill="caveman"',
'data-common-skill="unlazy"',
])
)
throw new Error('built full-guide lost its common-skill controls');
if (
!has(html.guide, [
'id="hands-on"',
'data-copy-target="prompt-install-skills"',
'data-copy-target="prompt-basic"',
'data-copy-target="prompt-skills"',
])
)
throw new Error('built full-guide lost its hands-on prompts');
if (!has(html.guide, ['hands-on/starter/', 'additional-reading.md', 'role="tablist"', '<table']))
throw new Error('built full-guide lost its guide navigation or semantics');
if (!html.guide.includes('<script type="module">'))
throw new Error('built full-guide lost its interaction module');
if (
!has(html.rules, [
'id="pipeline"',
'id="skills"',
'id="examples"',
'data-stage="context"',
'data-stage="cli"',
'data-stage="commit"',
'data-stage="review"',
])
)
throw new Error('built rules page lost its enforcement pipeline');
if (
!has(html.rules, [
'gate-discipline',
'parallel-agents',
'repo-db',
'tech-debt',
'skill-writer',
'scripts/check-ui-contract.mjs',
'.husky/pre-commit',
'.pr-review.json',
'.agents/skills',
'netcracker/interview',
])
)
throw new Error('built rules page lost its case-study content');
if (
!has(html.rules, [
'data-lang="en"',
'data-lang="pt"',
'data-copy-prompt',
'aria-live="polite"',
'role="tablist"',
])
)
throw new Error('built rules page lost its accessible controls');
if (!html.rules.includes('data-rules-copy') || !html.rules.includes('"pt"'))
throw new Error('built rules page lost its Portuguese interaction data');
if (
!has(html.rules, [
'id="recall"',
'data-copy="recallQ1"',
'data-copy="recallQ2"',
'data-copy="recallQ3"',
])
)
throw new Error('built rules page lost its retrieval-practice section');
if (!html.rules.includes('Dificuldade desejável') || !html.rules.includes('"recallA2"'))
throw new Error('built rules page lost its Portuguese recall copy');
if (
!has(html.review, [
'id="catalog"',
'id="skill-filter"',
'id="skill-list"',
'id="detail"',
'Preview Markdown',
'?author=Name&amp;skill=skill-id&amp;view=improved',
'SKILLS_REVIEW_VOTE_API',
])
)
throw new Error('built review desk lost its catalog or deep-link contract');
if (
!has(html.review, [
'ndo-repro',
'gfiber-logging',
'confluence-page',
'diagram-plantuml',
'page-reviewer',
'unslop',
'spanish-naturalizer',
'draft-mr',
'semantic-diff-review',
])
)
throw new Error('built review desk lost submitted-skill coverage');
if (
!html.review.includes('Anonymous%20Operational%20Submission') ||
!has(reviewAsset, ['[REDACTED]', '[REDACTED LOCAL USER]', '[REDACTED USER]'])
)
throw new Error('built review desk lost operational-submission privacy redactions');
if (!reviewAssetSrc) throw new Error('built review desk does not load its hashed island asset');
if (
![html.summary, html.models, html.agents, html.skills].every((page) => page.includes('ROUTE MAP'))
)
throw new Error('built chapter pages lost shared navigation');
if (
!has(html.landing, [
'The short route',
'full-guide/',
'models/',
'agents/',
'skills/',
'rules/',
'hands-on/starter/',
'skills-review/',
])
)
throw new Error('built landing page lost a route-map destination');
if (/<script\b/i.test(html.landing))
throw new Error('built landing page is no longer a static route map');
if (
!has(html.skills, [
'data-skill-file="skill"',
'data-skill-file="references"',
'data-skill-file="scripts"',
'data-skill-file="assets"',
'id="package-preview"',
])
)
throw new Error('built skills page lost its package explorer');
if (
!has(html.skills, [
'id="recall"',
'<summary',
'The skill never loads. What is the first suspect?',
'agentskills.io/specification',
'hands-on/starter/',
])
)
throw new Error('built skills page lost its recall section or citations');
if (
!has(read('src/content/chapters/skills.json'), [
'"recall"',
'"links"',
'Teste-se',
'Especificação do formato',
])
)
throw new Error('skills chapter lost its Portuguese recall copy');
if (!has(html.starter, ['id="task-list"', 'id="task-count"']))
throw new Error('starter lab lost its task-list contract');
const allPages = Object.entries(html);
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');
// `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');
if (!read('docs/references/README.md').includes('https://developers.openai.com/codex/skills'))
throw new Error('primary reference list lost Codex skills');
if ((read('docs/references/additional-reading.md').match(/^### \d+\./gm) || []).length < 5)
throw new Error('additional reading list has fewer than five entries');
if (
/Pedro\s+Aranha|pedro\.aranha|git\.netcracker\.com|artifactorycn|managed\.netcracker\.cloud/i.test(
read('src/content/reviews/ndo-repro.md'),
)
)
throw new Error('operational review content has a privacy leak');
// These retain the pre-Astro contract one fact at a time. Visible facts read
// from dist; source-only contracts remain at their authoritative boundary.
if (
![
'https://code.claude.com/docs/en/sub-agents',
'https://code.claude.com/docs/en/skills',
'https://code.claude.com/docs/en/worktrees',
'https://git-scm.com/docs/git-worktree.html',
'https://developers.openai.com/codex/skills',
].every((url) => source.references.includes(url))
)
throw new Error('missing reference contract');
if (!has(html.guide, ['data-phase="plan"', 'data-copy-target="prompt-basic"']))
throw new Error('missing full-guide content contract');
if (!has(html.guide, ['data-route="plan"', 'data-skill-file="skill"', '<script type="module">']))
throw new Error('missing full-guide interaction contract');
if (!has(html.starter, ['id="task-list"', 'id="task-count"']))
throw new Error('missing starter content contract');
if (!['const tasks', 'renderTasks()'].every((token) => source.starter.includes(token)))
throw new Error('missing starter behavior contract');
if (
![
'medium.com',
'anthropic.com/engineering',
'openai.com/business',
'git-scm.com/docs/git-worktree',
].every((url) => source.additionalReading.includes(url))
)
throw new Error('missing additional-reading source contract');
if ((source.additionalReading.match(/^### \d+\./gm) || []).length < 5)
throw new Error('additional-reading coverage fell below five entries');
if (
![
'e7b42dc2d384a702240dea4d52a7bf5530b821b6',
'6654f6b60cd9d5be8b54c6fafe44346dabeb3b76',
'53048666b05b4799081517d00e09e0a2dd688678',
].every((sha) => `${source.skillSources}\n${html.guide}`.includes(sha))
)
throw new Error('missing pinned skill-source contract');
if (
![
'developers.openai.com/api/docs/guides/latest-model',
'docs.anthropic.com/en/docs/claude-code/model-config',
'ai.google.dev/gemini-api/docs/thinking',
].every((url) => `${source.providers}\n${html.guide}`.includes(url))
)
throw new Error('missing model-source contract');
if (/<(script|link)[^>]+(src|href)="https?:[^\"]+"/i.test(html.guide))
throw new Error('full-guide has an external runtime dependency');
if (!has(html.rules, ['id="pipeline"', 'id="skills"', 'id="examples"']))
throw new Error('missing rules content contract');
if (!has(html.rules, ['data-stage="context"', 'data-copy-prompt', '<script>']))
throw new Error('missing rules interaction contract');
if (!has(html.rules, ['data-lang="en"', 'data-lang="pt"', 'aria-live="polite"']))
throw new Error('missing rules-control contract');
if (!html.guide.includes('href="/ai-for-dummies/rules/"'))
throw new Error('full-guide no longer links to rules');
if (!html.guide.includes('href="/ai-for-dummies/skills-review/"'))
throw new Error('full-guide no longer links to skills review');
if (!['summary/', 'models/', 'agents/', 'skills/'].every((route) => html.guide.includes(route)))
throw new Error('full-guide is missing a chapter route');
if (/<(script|link)[^>]+(src|href)="https?:[^\"]+"/i.test(html.rules))
throw new Error('rules page has an external runtime dependency');
if (
!['@media(max-width:800px)', '@media(min-width:2200px)', 'prefers-reduced-motion'].every(
(token) => builtCss.includes(token),
)
)
throw new Error('missing rules responsive contract');
if (
![
'body{min-width:320px;margin:0;color:var(--ink);background:var(--paper);font-family:var(--font-sans)}',
'max-width:75ch;font-size:var(--step-24)',
'max-width:75ch;font-size:var(--step-20)',
].every((token) => builtCss.includes(token))
)
throw new Error('rules page lost its global font or 4K prose scale');
if (
!['font:700 var(--step-18) var(--font-mono)', 'font:500 var(--step-48) var(--font-mono)'].every(
(token) => builtCss.includes(token),
)
)
throw new Error('full-guide lost its effort or skill-display type scale');
if (!has(html.review, ['id="catalog"', 'id="skill-filter"', 'id="skill-list"', 'id="detail"']))
throw new Error('missing review page-content contract');
if (
!['data-render', 'preview-markdown', 'data-file', 'URLSearchParams', 'navigator.clipboard'].every(
(token) => reviewAsset.includes(token),
)
)
throw new Error('missing review interaction contract');
if (
!['reference.md', 'ndo-repro', 'semantic-diff-review'].every((token) =>
html.review.includes(token),
)
)
throw new Error('missing review file-manifest contract');
// The desk renders its cards from an inline JSON payload, so `data-skill-id`
// only exists after the island runs. Count the entries in the payload instead.
if ((html.review.match(/\\"id\\":/g) || []).length !== 24)
throw new Error('review catalog no longer covers all submissions');
if (!source.ndoReview.includes('safety-redacted') || !reviewAsset.includes('[REDACTED]'))
throw new Error('review catalog lost its secret-safety contract');
if (
![html.summary, html.models, html.agents, html.skills].every((page) => page.includes('ROUTE MAP'))
)
throw new Error('chapter page missing shared navigation');
if (
!['@media(max-width:800px)', '@media(max-width:520px)', '--ink'].every((token) =>
builtCss.includes(token),
)
)
throw new Error('missing chapter responsive contract');
if (!has(html.landing, ['The short route', 'full-guide/', 'skills-review/']))
throw new Error('landing page missing a route-map destination');
if (/<script\b/i.test(html.landing))
throw new Error('landing page is no longer a static route map');
if (
!has(html.skills, [
'data-skill-file="skill"',
'data-skill-file="references"',
'id="package-preview"',
])
)
throw new Error('skills anatomy missing package contract');
if (!html.skills.includes('<script>'))
throw new Error('skills anatomy lost its interaction module');
if (
!['overflow-wrap:anywhere', '@media(max-width:800px)', 'prefers-reduced-motion'].every((token) =>
builtCss.includes(token),
)
)
throw new Error('skills anatomy lost responsive contract');
if (
/Pedro\s+Aranha|pedro\.aranha|git\.netcracker\.com|artifactorycn|managed\.netcracker\.cloud/i.test(
source.ndoReview,
)
)
throw new Error('operational submission privacy leak');
if (html.review.includes('Pedro%20Aranha') || html.review.includes('pedro.aranha'))
throw new Error('operational submission identity remains in review output');
if (
!html.review.includes('Anonymous%20Operational%20Submission') ||
!reviewAsset.includes('[REDACTED LOCAL USER]')
)
throw new Error('operational submission redaction contract missing');
if (!['dataset.preview', 'dataset.file'].every((token) => reviewAsset.includes(token)))
throw new Error('review file-mode contract missing');
if (!reviewAsset.includes('preview-markdown'))
throw new Error('review file selection no longer preserves preview mode');
if (!['data-lens', 'CHANGE LENS', 'What changed'].every((token) => reviewAsset.includes(token)))
throw new Error('review change-lens contract missing');
if (
!['.change-lens', '.change-rows', '.skill-diff', '@media(max-width:620px)'].every((token) =>
builtCss.includes(token),
)
)
throw new Error('review change-lens CSS contract missing');
if (
!['.markdown-preview', 'max-height:540px', '.markdown-table-wrap', '.markdown-toc'].every(
(token) => builtCss.includes(token),
)
)
throw new Error('review markdown-preview contract missing');
if (
!html.review.includes(
'src="/ai-for-dummies/_astro/skills-review.astro_astro_type_script_index_0_lang.',
)
)
throw new Error('review vote-widget island is not wired to a hashed asset');
if (
!['id="vote-widget"', 'X-Voter-Id', '/api/votes', 'Voting is offline'].every((token) =>
`${html.review}\n${reviewAsset}`.includes(token),
)
)
throw new Error('review vote-widget contract missing');
// The CSS minifier drops the attribute value's quotes, so the built sheet
// carries `[aria-pressed=true]`. Match either form.
if (
!['.vote-widget', '.vote-buttons'].every((token) => builtCss.includes(token)) ||
!/\[aria-pressed=["']?true["']?\]/.test(builtCss)
)
throw new Error('review vote-widget CSS contract missing');
// The `vote-service` one-vote-per-IP assertion was removed when the service's
// source left this repository. It read `vote-service/main.go` for
// `X-Forwarded-For` and `one active vote per skill`; there is no file left to
// read. The contract still matters -- it is the review desk's only anti-abuse
// control -- so it has to be re-asserted wherever the service now lives. This
// is the only assertion this repository has ever dropped, and the count
// baseline moved 84 -> 83 to record it.
console.log('built output verification passed');