feat(build): audit what the site actually serves, in the build and on the wire

Everything here checked an input: the content check reads the data, the secret
scan reads the diff, the build reads the source. Nothing read the OUTPUT, which
is the only thing a visitor or a crawler ever sees. Two live defects made the
case: every page preloaded the wrong image for months, and eleven pages shipped
a run-on description. Both are plain in the built HTML and invisible in the
source.

Build mode is guard 15-built-html, after 10-build. Per page it requires exactly
one title, one non-empty description, one canonical equal to the site origin plus
the route, and one h1; JSON-LD that parses, with no FAQPage, which the owner
ruled out; no em dash and no U+FFFD; a preload naming the image the page actually
paints first; and no description that runs its short description into the next
sentence. Across pages it requires every internal link and every fragment to
resolve, the sitemap to list exactly the routes the site serves, and 404.html to
carry noindex and no canonical. It exits 2, not 0, when dist/ is missing or older
than the sources: auditing stale output is auditing nothing.

That also guards a specific hazard. react-helmet-async on React 19 does not
merge, so a second <SEO> anywhere on a page silently emits a second title and a
second canonical, and a search engine picks whichever it likes.

URL mode fetches every page in a live sitemap once per crawler user agent
(OAI-SearchBot, PerplexityBot, ClaudeBot, Googlebot, bingbot), requires HTTP 200
and identical bytes across agents, runs the same page rules, and reports any URL
without a lastmod. It is deliberately NOT wired into deploy.sh: a check that runs
after publication cannot stop it, and pretending otherwise is worse than not
having it. Run it after a deploy.

Proven by mutation, nine of them, each restored afterwards: a wrong canonical
(7 pages), a second h1 (4), FAQPage markup, an em dash in copy, a link to a route
that does not exist (18), a fragment that is not on its target page (7), the
preload keyed on the old attribute (19), the template title left in place giving
two titles (19), and the industry routes dropped from the route list (56). A
clean build audits clean, and URL mode passes against the local server as all
five crawlers.

Closes #228.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-09-10 04:51:08 -05:00
parent 7ec4241f7d
commit ee5186c1ee
7 changed files with 379 additions and 10 deletions

View File

@ -63,6 +63,7 @@ git config core.hooksPath .githooks # per clone. Not optional. See below
bash scripts/check-env.sh --file .env # what is configured, before anything reads it bash scripts/check-env.sh --file .env # what is configured, before anything reads it
bash scripts/secrets.sh --tracked # what is already committed bash scripts/secrets.sh --tracked # what is already committed
node scripts/validate-content.js # whether the copy in src/data is publishable node scripts/validate-content.js # whether the copy in src/data is publishable
node scripts/audit-html.js # what the built pages actually say
``` ```
`npm run verify` runs that scan too, as guard `20-secrets`, together with `npm run verify` runs that scan too, as guard `20-secrets`, together with
@ -140,9 +141,10 @@ Run from the repository root.
**There is no `npm test`, and that is not an omission in this table.** There is **There is no `npm test`, and that is not an omission in this table.** There is
no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a
standing gap — and it is why `npm run release` says out loud that its gate is a standing gap — and it is why `npm run release` says out loud what its gate
build, a secret scan and a doc-header check rather than pretending those are actually is: a build that validates the content layer, an audit of the built
tests. HTML, a secret scan of the tree and the bundle, and a doc-header check. None of
those exercises a form, an API response, or a page in a browser.
**`release` and `deploy` are two commands on purpose.** Publishing an image and **`release` and `deploy` are two commands on purpose.** Publishing an image and
running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates
@ -201,9 +203,12 @@ Exit 2 means playwright was missing or the site was unreachable — nothing was
checked, which is not a pass. checked, which is not a pass.
**`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and **`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and
requires the guard to go red. This project has three guards, all shell scripts requires the guard to go red. This project has four guards, all shell scripts
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand that fail visibly, so §1 of `architecture/GUARDS.md` is performed by hand
instead — see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18. instead: see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 and 2026-09-10.
The four are `10-build` (which also runs the content check and the route-drift
check inside the prerenderer), `15-built-html`, `20-secrets` (tracked tree and
bundle) and `30-doc-headers`.
## Adding a script ## Adding a script

View File

@ -12,7 +12,7 @@ Review trigger: A guard is found to have been passing while the thing it guards
> **prove-guard.sh is not in this repository.** It lives in the template at > **prove-guard.sh is not in this repository.** It lives in the template at
> `~/.openclaw/Projects/Template/docs/architecture/scripts/`, and this project > `~/.openclaw/Projects/Template/docs/architecture/scripts/`, and this project
> declined it on adoption — its guards are three shell scripts in > declined it on adoption — its guards are four shell scripts in
> `scripts/verify.d/` that fail visibly on their own. It is named without > `scripts/verify.d/` that fail visibly on their own. It is named without
> backticks throughout for that reason. §1 below still applies and was performed > backticks throughout for that reason. §1 below still applies and was performed
> by hand on every guard here; `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 > by hand on every guard here; `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18

View File

@ -203,6 +203,7 @@ each row says what it does *here*.
| `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row | | `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row |
| `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after | | `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after |
| `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed | | `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed |
| `scripts/audit-html.js` | what the site actually serves: over `dist/` as guard `15-built-html`, and with `--url` against a live origin once per crawler user agent. The URL run is a check to make after a deploy, not a gate |
| `scripts/validate-content.js` | the content check on its own, for proving it fails and for a fast answer while writing copy. `npm run build` runs the same check inside the prerenderer, so a clean run here is not a substitute for a build | | `scripts/validate-content.js` | the content check on its own, for proving it fails and for a fast answer while writing copy. `npm run build` runs the same check inside the prerenderer, so a clean run here is not a substitute for a build |
| `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table) and `content.js` (what must be true of `src/data/**` before a page is built from it) | | `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table) and `content.js` (what must be true of `src/data/**` before a page is built from it) |
| `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only | | `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only |

178
scripts/audit-html.js Normal file
View File

@ -0,0 +1,178 @@
#!/usr/bin/env node
//
// Audits what the site actually serves, in two modes.
//
// node scripts/audit-html.js # dist/, run as guard 15-built-html
// node scripts/audit-html.js --url https://queuenorth.com
// node scripts/audit-html.js --url http://localhost:3001 --agents Googlebot
//
// Build mode is a gate: it reads dist/ and refuses on a finding. URL mode
// fetches every page in the live sitemap once per crawler user agent and reports
// what those crawlers actually receive. URL mode is a check to run AFTER a
// deploy, deliberately not wired into deploy.sh: a check that runs after
// publication cannot stop it, and pretending otherwise is worse than not having
// it (GUARDS.md rule 6).
//
// Exit 0 clean, 1 findings, 2 nothing was audited.
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { auditLinks, auditPage, auditSitemap, parseSitemap } from './lib/html-audit.js'
import { ROUTES } from './lib/routes.js'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const distDir = path.join(root, 'dist')
// The crawlers this site is written for. A spoofed agent string is not the real
// crawler, and a "verified bots only" rule at the edge would answer this with a
// 403 while serving the real one, so treat a pass as evidence the ORIGIN is not
// blocking, not as proof the crawler is happy.
const AGENTS = {
'OAI-SearchBot': 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)',
PerplexityBot: 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)',
ClaudeBot: 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)',
Googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
}
const argv = process.argv.slice(2)
const flag = (name) => {
const at = argv.indexOf(`--${name}`)
return at === -1 ? null : argv[at + 1]
}
const origin = flag('url')
const agents = (flag('agents')?.split(',') ?? Object.keys(AGENTS)).filter((name) => AGENTS[name])
const descriptionSource = (routePath) => {
const service = services.find((item) => routePath === `/services/${item.id}`)
if (service) return { shortDesc: service.shortDesc, approvedDescription: Boolean(service.page?.seo?.description) }
const industry = industries.find((item) => routePath === `/industries/${item.id}`)
if (industry) return { shortDesc: industry.shortDesc, approvedDescription: false }
return { shortDesc: null, approvedDescription: false }
}
const report = (findings, checked, what) => {
if (findings.length) {
console.error(`audit: ${findings.length} finding(s) in ${what}:`)
for (const finding of findings) console.error(` ${finding}`)
process.exit(1)
}
console.log(`audit: ${checked} page(s) in ${what}, nothing wrong.`)
}
// --- build mode --------------------------------------------------------------
const newestSourceMtime = () => {
let newest = 0
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else newest = Math.max(newest, statSync(full).mtimeMs)
}
}
for (const dir of ['src', 'public', 'scripts/lib']) walk(path.join(root, dir))
for (const file of ['index.html', 'scripts/prerender.js']) newest = Math.max(newest, statSync(path.join(root, file)).mtimeMs)
return newest
}
const auditBuild = () => {
const sitemapPath = path.join(distDir, 'sitemap.xml')
if (!existsSync(sitemapPath)) {
console.error('audit: dist/sitemap.xml is missing, so the build never finished and NOTHING was audited. Run npm run build.')
process.exit(2)
}
// The prerender writes the sitemap last. A source file newer than it means
// dist/ is stale, and auditing stale output is auditing nothing.
if (newestSourceMtime() > statSync(sitemapPath).mtimeMs) {
console.error('audit: dist/ is older than the sources, so NOTHING was audited. Run npm run build.')
process.exit(2)
}
const entries = parseSitemap(readFileSync(sitemapPath, 'utf8'))
const findings = [...auditSitemap(entries)]
const pages = []
for (const routePath of ROUTES) {
const file = routePath === '/' ? path.join(distDir, 'index.html') : path.join(distDir, routePath, 'index.html')
if (!existsSync(file)) {
findings.push(`${routePath}: the build produced no page, so the server would answer it with 404.html`)
continue
}
const html = readFileSync(file, 'utf8')
pages.push([routePath, html])
findings.push(...auditPage(html, { path: routePath, ...descriptionSource(routePath) }))
}
const notFound = path.join(distDir, '404.html')
if (!existsSync(notFound)) findings.push('404.html: missing from the build')
else findings.push(...auditPage(readFileSync(notFound, 'utf8'), { path: '/404', notFound: true }))
findings.push(...auditLinks(pages, { fileExists: (href) => existsSync(path.join(distDir, href.replace(/^\//, ''))) }))
report(findings, pages.length, 'the build')
}
// --- url mode ----------------------------------------------------------------
const fetchAs = async (url, agent) => {
const response = await fetch(url, {
headers: { 'User-Agent': AGENTS[agent] },
redirect: 'manual',
signal: AbortSignal.timeout(20000),
})
return { status: response.status, body: await response.text() }
}
const auditOrigin = async () => {
let entries
try {
const response = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
entries = parseSitemap(await response.text())
} catch (error) {
console.error(`audit: could not read ${origin}/sitemap.xml (${error.message}), so NOTHING was audited.`)
process.exit(2)
}
if (!entries.length) {
console.error(`audit: ${origin}/sitemap.xml lists no pages, so NOTHING was audited.`)
process.exit(2)
}
const findings = [...auditSitemap(entries, { requireLastmod: true })]
const pages = []
for (const entry of entries) {
const url = `${origin}${entry.path}`
const bodies = new Map()
for (const agent of agents) {
let result
try {
result = await fetchAs(url, agent)
} catch (error) {
findings.push(`${entry.path}: ${agent} could not fetch it (${error.message})`)
continue
}
if (result.status !== 200) findings.push(`${entry.path}: ${agent} got HTTP ${result.status}`)
bodies.set(agent, result.body)
}
const distinct = new Set(bodies.values())
if (distinct.size > 1) {
findings.push(`${entry.path}: crawlers were served different bytes (${bodies.size} agents, ${distinct.size} versions)`)
}
const body = bodies.values().next().value
if (body) {
pages.push([entry.path, body])
findings.push(...auditPage(body, { path: entry.path, ...descriptionSource(entry.path) }))
}
}
findings.push(...auditLinks(pages, { fileExists: () => true }))
report(findings, pages.length, `${origin} as ${agents.length} crawler(s)`)
}
if (origin) await auditOrigin()
else auditBuild()

162
scripts/lib/html-audit.js Normal file
View File

@ -0,0 +1,162 @@
// What must be true of a page after it is built, and again after it is served.
//
// ## Why this exists
//
// Everything else in this repository checks an input: the content check reads
// the data, the secret scan reads the diff, the build reads the source. Nothing
// read the OUTPUT, and the output is the only thing a visitor or a crawler ever
// sees. Two live defects made the case: every page preloaded the wrong image for
// months, and eleven pages shipped a description that read as one run-on
// sentence. Both are obvious in the built HTML and invisible in the source.
//
// It also guards a specific hazard. react-helmet-async on React 19 does not
// merge: a second <SEO> anywhere on a page silently emits a second title and a
// second canonical, and search engines pick whichever they like.
//
// ## Two modes, one set of rules
//
// Build mode reads dist/ and is a gate. URL mode fetches a live origin once per
// crawler user agent and is a check to run after a deploy, not a gate, because a
// check that runs after publication cannot stop it.
import { SITE_URL } from '../../src/lib/seo.js'
import { ROUTES } from './routes.js'
// React writes its own text separators as comments, and the index.html template
// carries a commented-out preload. Anything counting tags must strip comments
// first or it will count that one.
export const stripComments = (html) => html.replace(/<!--[\s\S]*?-->/g, '')
export const parseSitemap = (xml) =>
[...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((entry) => {
const loc = entry[1].match(/<loc>([^<]+)<\/loc>/)?.[1] ?? ''
const lastmod = entry[1].match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? null
let path = '/'
try {
path = new URL(loc).pathname
} catch {
path = loc
}
return { loc, path, lastmod }
})
const all = (html, pattern) => [...html.matchAll(pattern)]
/**
* Every rule that applies to one page.
* @returns {string[]} findings, each a sentence naming what is wrong
*/
export const auditPage = (rawHtml, { path, notFound = false, shortDesc = null, approvedDescription = false }) => {
const html = stripComments(rawHtml)
const [head = '', body = ''] = html.split('</head>')
const findings = []
const say = (detail) => findings.push(`${notFound ? '404.html' : path}: ${detail}`)
const titles = all(head, /<title[^>]*>([\s\S]*?)<\/title>/g)
if (titles.length !== 1) say(`${titles.length} <title> tags in <head>, expected exactly 1`)
else if (!titles[0][1].trim()) say('an empty <title>')
const descriptions = all(head, /<meta name="description" content="([^"]*)"/g)
if (descriptions.length !== 1) say(`${descriptions.length} meta descriptions, expected exactly 1`)
else if (!descriptions[0][1].trim()) say('an empty meta description')
const canonicals = all(head, /<link rel="canonical" href="([^"]+)"/g)
if (notFound) {
if (canonicals.length) say('a canonical, which a 404 must not claim')
if (!/name="robots" content="[^"]*noindex/.test(head)) say('no noindex, so the 404 page invites indexing')
} else if (canonicals.length !== 1) {
say(`${canonicals.length} canonicals, expected exactly 1`)
} else {
const expected = `${SITE_URL}${path === '/' ? '' : path}`
if (canonicals[0][1] !== expected) say(`canonical is ${canonicals[0][1]}, expected ${expected}`)
}
const h1s = all(body, /<h1[\s>]/g)
if (h1s.length !== 1) say(`${h1s.length} <h1> tags, expected exactly 1`)
for (const block of all(html, /<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/g)) {
try {
const parsed = JSON.parse(block[1])
const types = JSON.stringify(parsed).match(/"@type":"([A-Za-z]+)"/g) || []
// The approved sheets are explicit: the FAQ is visible page copy and gets
// no FAQPage markup.
if (types.some((type) => type.includes('FAQPage'))) say('FAQPage structured data, which the owner ruled out')
} catch (error) {
say(`structured data that does not parse: ${error.message}`)
}
}
if (/—/.test(html)) say('an em dash, which Null asked for nowhere a visitor or crawler reads')
if (/<2F>/.test(html)) say('a U+FFFD replacement character, so something was decoded with the wrong encoding')
// The preload must name the image the page actually paints first.
const hero = body.match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0]
const heroSrc = hero?.match(/\bsrc="([^"]+)"/i)?.[1]
const preloads = all(head, /<link rel="preload"[^>]*as="image"[^>]*href="([^"]+)"/g)
if (heroSrc) {
if (preloads.length !== 1) say(`${preloads.length} image preloads, expected exactly 1 for ${heroSrc}`)
else if (preloads[0][1] !== heroSrc) say(`preloads ${preloads[0][1]} while the hero image is ${heroSrc}`)
} else if (preloads.length) {
say(`preloads ${preloads[0][1]} while the page paints no high-priority image`)
}
// The run-on this project shipped for months: a short description followed
// straight by the next sentence with no full stop between them.
if (shortDesc && !approvedDescription && descriptions.length === 1) {
const stripped = shortDesc.replace(/\s+/g, ' ').trim()
const description = descriptions[0][1].replace(/\s+/g, ' ')
if (description.includes(`${stripped} `) && !description.includes(`${stripped}. `)) {
say('the description runs its short description into the next sentence with no full stop')
}
}
return findings
}
/** ids and internal links, across every page at once. */
export const auditLinks = (pages, { fileExists = () => true } = {}) => {
const findings = []
const idsByPath = new Map()
for (const [path, html] of pages) {
idsByPath.set(path, new Set([...stripComments(html).matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])))
}
for (const [path, html] of pages) {
const body = stripComments(html).split('</head>')[1] ?? ''
const seen = new Set()
for (const match of all(body, /href="(\/[^"]*)"/g)) {
const href = match[1]
if (seen.has(href)) continue
seen.add(href)
const [route, fragment] = href.split('#')
const target = route === '' ? path : route
if (route && !ROUTES.includes(route)) {
// Not a route, so it must be a file the build actually emits.
if (!fileExists(route)) findings.push(`${path}: links to ${route}, which is neither a route nor a file in the build`)
continue
}
if (fragment) {
const ids = idsByPath.get(target)
if (ids && !ids.has(fragment)) findings.push(`${path}: links to ${href}, and #${fragment} is not on that page`)
}
}
}
return findings
}
/** The sitemap must list exactly the routes this site serves. */
export const auditSitemap = (entries, { requireLastmod = false } = {}) => {
const findings = []
const listed = new Set(entries.map((entry) => entry.path))
for (const route of ROUTES) if (!listed.has(route)) findings.push(`sitemap.xml: does not list ${route}`)
for (const path of listed) if (!ROUTES.includes(path)) findings.push(`sitemap.xml: lists ${path}, which is not a route`)
if (requireLastmod) {
const undated = entries.filter((entry) => !entry.lastmod).length
if (undated) {
findings.push(
`sitemap.xml: ${undated} of ${entries.length} URLs carry no lastmod, so search engines cannot tell what changed`,
)
}
}
return findings
}

View File

@ -315,9 +315,11 @@ if ! bash scripts/verify.sh; then
exit 1 exit 1
fi fi
say "NOTE: those guards are a build, a secret scan and a doc-header check." say "NOTE: those guards are a build (which validates the content layer), an"
say " There is no test suite in this repository, so nothing above" say " audit of the built HTML, a secret scan of the tree and the bundle,"
say " exercised a single route, form or API response." say " and a doc-header check. There is no test suite in this repository,"
say " so nothing above exercised a form or an API response, and nothing"
say " loaded a page in a browser."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build, verify what came out, then push. Nothing is committed until all three # Build, verify what came out, then push. Nothing is committed until all three

21
scripts/verify.d/15-built-html Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
#
# What the site actually serves, checked after it is built.
#
# Every other guard here reads an input: the content check reads the data, the
# secret scan reads the diff, the build reads the source. This one reads the
# OUTPUT, which is the only thing a visitor or a crawler ever sees. Two live
# defects made the case for it: every page preloaded the wrong image for months,
# and eleven pages shipped a description that read as one run-on sentence. Both
# are plain in the built HTML and invisible in the source.
#
# It sorts after 10-build on purpose: there is nothing to read until the build
# has run, and it refuses (exit 2) rather than pass when dist/ is missing or
# older than the sources.
#
# Exit 0 clean, 1 findings, 2 nothing was audited.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
[ -f scripts/audit-html.js ] || { echo "audit: scripts/audit-html.js is missing, so nothing was audited." >&2; exit 2; }
node scripts/audit-html.js