Files
adr-sml/scripts/lighthouse.mjs
T
Pouya LajevardiandClaude Opus 5 bd282aa47d
Build and deploy / build-and-deploy (push) Failing after 4s
feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 06:52:20 -04:00

388 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* The performance gate. Budget: docs/04-seo-spec.md §Performance —
* Lighthouse >= 95 on all four categories, on mobile, for every page.
*
* WHY THIS IS `lighthouse` AND NOT `@lhci/cli`, WHICH IS WHAT R11 SAID TO PUT
* BACK. Measured 2026-08-31 from two probe lockfiles, not recalled:
*
* @lhci/cli@0.15.1 10 vulnerabilities (7 high) pins lighthouse 12.6.1
* high: tmp@0.1.0 <- a DIRECT dependency of @lhci/cli itself
* high: extract-zip@2.0.1 <- via @puppeteer/browsers
* lighthouse@13.4.1 0 vulnerabilities 109 packages
* tmp ABSENT, extract-zip ABSENT
*
* So the carrier was never Lighthouse. AGENTS.md §7 recorded the advisories as
* arriving "via lighthouse -> puppeteer-core -> extract-zip", and on that
* attribution the tool looked unusable for as long as the advisories stood.
* Standalone `lighthouse` measures the same budget with nothing outstanding.
* What is given up is real and is recorded in §7: `lhci autorun`'s assertion
* config, its server, and its CI upload.
*
* THIS IS A LOCAL GATE, NOT A CI CHECK, and the reason is Chrome. Standalone
* Lighthouse drives an installed browser; the Gitea runner has none (§7 — the
* runner is not registered at all yet, Q23). So this runs from a keyboard and
* as a blocking item on docs/06's cutover checklist. It is not wired into
* `npm run build` or either deploy path, and saying so is the point: a check
* described as running where it cannot is the defect Q22 turned out to be.
*
* PAGES ARE ENUMERATED FROM `dist/`, NEVER LISTED HERE. A hand-written list
* silently stops covering the site the first time a page is added — which is
* this project's most expensive recurring shape. Every `index.html` under
* `dist/` is a page, so the set cannot go stale.
*
* Usage: npm run build && npm run lighthouse
* npm run lighthouse -- /fees/ /insights/ # a subset, by pathname
*/
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, extname, relative, sep } from 'node:path';
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
const DIST = new URL('../dist/', import.meta.url).pathname;
const THRESHOLD = 95;
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'];
/** docs/04's own budgets, reported alongside the scores rather than asserted
* separately — LCP is the one the spec states in seconds. */
const LCP_BUDGET_MS = 2000;
const CLS_BUDGET = 0.05;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.avif': 'image/avif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.pdf': 'application/pdf',
};
/**
* `trailingSlash: 'always'` + `build.format: 'directory'` (astro.config.mjs),
* so `/mediation/` is `dist/mediation/index.html` and an extensionless path
* without the slash is a 404 here exactly as it is on CloudFront. Serving it
* anyway would measure a URL the site does not have.
*/
function resolveFile(pathname) {
if (pathname.endsWith('/')) return join(DIST, pathname, 'index.html');
if (extname(pathname)) return join(DIST, pathname);
return null;
}
async function collectPages(dir = DIST) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await collectPages(full)));
else if (entry.name === 'index.html') {
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
out.push(rel ? `/${rel}/` : '/');
}
/* ⚠️ `index.html` ALONE MISSED THE 404 PAGE, so the budget was measured on
22 pages of 23 while the header above claims it enumerates the site.
`404.astro` is emitted as `dist/404.html`, outside `build.format:
'directory'`. The path pushed here is a URL this script SERVES, so it is
`/404.html` — the form CloudFront's custom error response fetches — and
`resolveFile()` resolves it on the `extname` branch. `og-proof.mjs` needs
the `OG_CARDS` key `/404/` for the same file; the two differ on purpose. */
else if (dir === DIST && entry.name.endsWith('.html')) {
out.push(`/${entry.name}`);
}
}
return out.sort();
}
function serveDist() {
const server = createServer((req, res) => {
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const file = resolveFile(pathname);
if (!file) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('404');
return;
}
const stream = createReadStream(file);
stream.on('error', () => {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('404');
});
stream.once('open', () => {
// NO `cache-control` HEADER, AND THAT IS DELIBERATE — measured
// 2026-08-31. `cache-control: no-store` was set here to force a cold
// cache, which it did not need to do (Lighthouse resets storage between
// runs by default) and which cost the `bf-cache` audit outright:
// "Pages whose main resource has cache-control:no-store cannot enter
// back/forward cache." The audit failed on every page, in a report whose
// whole job is to find defects on the site. Verified by toggling the one
// header: bf-cache 0 with it, 1 without, twice each.
res.writeHead(200, {
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
});
stream.pipe(res);
});
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () =>
resolve({ server, port: server.address().port }),
);
});
}
const pad = (s, n) => String(s).padEnd(n);
const scoreOf = (lhr, id) => Math.round((lhr.categories[id]?.score ?? 0) * 100);
/**
* ⚠️ AN INTENTIONALLY `noindex` PAGE CANNOT SCORE 95 ON LIGHTHOUSE'S SEO
* CATEGORY, AND THE BUDGET AS WRITTEN DID NOT KNOW THAT.
*
* Measured 2026-08-31, first full run over 22 pages: five pages scored SEO
* **69**, and on every one the ONLY failing audit was `is-crawlable` — *"Page is
* blocked from indexing"* — firing on `<meta name="robots" content="noindex,
* follow">`. That meta tag is what `docs/04` REQUIRES on `/legal/*`, and it is
* deliberate on `/bio/`, `/contact/received/` and `/contact/could-not-send/`.
* So the category is measuring the page doing exactly what it was built to do.
*
* The wrong fix is to drop the SEO threshold, or to except these pages, or to
* stop measuring them: each of those hides every OTHER SEO defect on the pages
* where a defect is hardest to notice. What is asserted instead is stricter than
* a number:
*
* indexable page -> SEO category >= 95, as before
* noindex page -> EVERY SEO audit must pass EXCEPT `is-crawlable`
*
* A missing canonical, a missing title, an unreadable font size or a bad link on
* a noindex page still fails the gate. Only the one audit that is measuring the
* intent is set aside, and the page is marked in the table so the number is
* never read as unqualified.
*
* `noindex` is read from the BUILT HTML rather than from a list of paths here —
* a list would stop covering the site the first time a page is added.
*/
const EXPECTED_NOINDEX_FAILURE = 'is-crawlable';
async function isNoindex(page) {
const file = resolveFile(page);
const html = await readFile(file, 'utf8');
return /<meta[^>]+name="robots"[^>]+content="[^"]*noindex/i.test(html);
}
function failingAudits(lhr, category) {
return (lhr.categories[category]?.auditRefs ?? [])
.map((ref) => lhr.audits[ref.id])
.filter((audit) => audit && audit.score !== null && audit.score < 1)
.map((audit) => audit.id);
}
async function main() {
try {
await stat(join(DIST, 'index.html'));
} catch {
console.error(
'dist/index.html is missing. Run `npm run build` first — this gate ' +
'measures the bytes that would ship, not the dev server.',
);
process.exit(2);
}
const requested = process.argv.slice(2).filter((a) => a.startsWith('/'));
const all = await collectPages();
const pages = requested.length ? requested : all;
const unknown = requested.filter((p) => !all.includes(p));
if (unknown.length) {
console.error(`Not built: ${unknown.join(', ')}`);
process.exit(2);
}
const { server, port } = await serveDist();
const baseFlags = ['--headless', '--no-sandbox', '--disable-gpu'];
const chrome = await chromeLauncher.launch({ chromeFlags: baseFlags });
/**
* ⚠️ A SECOND BROWSER, AND THE ACCESSIBILITY CATEGORY IS MEASURED IN IT.
*
* `--force-prefers-reduced-motion`. This is a deliberate deviation from a
* single default run and it must be stated wherever the number is, which is
* why the table below labels the column. Measured 2026-08-31, twice per
* condition, on `/process/`:
*
* motion on a11y = 96 color-contrast FAILED, 24 nodes
* motion off a11y = 100 color-contrast passed, 0 nodes
*
* The 24 nodes were the scroll-driven reveal (`animation-timeline: view()`,
* global.css) caught mid-flight: axe reported foregrounds like `#d0cbc4` on
* `#f8f4ed`, and NEITHER is in this site's palette — they are the real colours
* blended toward the background by an in-progress `opacity` keyframe. So the
* audit was measuring animation state, not contrast.
*
* WHY THIS IS THE HONEST RUN RATHER THAN THE CONVENIENT ONE. A category that
* reports 24 known-false nodes on ten of fourteen pages cannot surface the
* twenty-fifth, real one — it is a control that has stopped controlling, which
* is the shape `AGENTS.md` Q22 and the Lighthouse removal both took. The
* reduced-motion rendering is not a synthetic one: it is the branch
* `global.css` ships for `prefers-reduced-motion: reduce`, a real user setting,
* and it is the branch in which every element sits at its FINAL colour, which
* is what a contrast audit is asking about. Contrast ratios for the palette
* itself are computed and recorded in `docs/02-design-system.md`.
*
* Performance is NOT measured here — reduced motion would suppress work the
* site really does on a default profile.
*/
const chromeA11y = await chromeLauncher.launch({
chromeFlags: [...baseFlags, '--force-prefers-reduced-motion'],
});
const PERF_CATEGORIES = CATEGORIES.filter((id) => id !== 'accessibility');
const rows = [];
const breaches = [];
try {
for (const page of pages) {
const url = `http://127.0.0.1:${port}${page}`;
// Default config otherwise: Lighthouse's mobile preset — mobile form
// factor, mobile screen emulation, simulated Slow 4G. That is the
// budget's own wording in docs/04, so none of it is overridden.
const run = async (chromeInstance, onlyCategories) => {
const result = await lighthouse(url, {
logLevel: 'error',
output: 'json',
port: chromeInstance.port,
onlyCategories,
});
if (!result?.lhr) {
throw new Error(`Lighthouse returned nothing for ${page}`);
}
if (result.lhr.runtimeError?.code) {
throw new Error(`${page}: ${result.lhr.runtimeError.message}`);
}
return result.lhr;
};
const lhr = await run(chrome, PERF_CATEGORIES);
const lhrA11y = await run(chromeA11y, ['accessibility']);
const scores = Object.fromEntries([
...PERF_CATEGORIES.map((id) => [id, scoreOf(lhr, id)]),
['accessibility', scoreOf(lhrA11y, 'accessibility')],
]);
const lcp = lhr.audits['largest-contentful-paint']?.numericValue ?? NaN;
const cls = lhr.audits['cumulative-layout-shift']?.numericValue ?? NaN;
const noindex = await isNoindex(page);
rows.push({ page, scores, lcp, cls, noindex });
for (const id of CATEGORIES) {
// The SEO category on a noindex page is asserted audit by audit
// instead — see the comment on EXPECTED_NOINDEX_FAILURE.
if (id === 'seo' && noindex) continue;
if (scores[id] < THRESHOLD) {
breaches.push(`${page} ${id} = ${scores[id]} (< ${THRESHOLD})`);
}
}
if (noindex) {
const unexpected = failingAudits(lhr, 'seo').filter(
(id) => id !== EXPECTED_NOINDEX_FAILURE,
);
if (unexpected.length) {
breaches.push(
`${page} seo — noindex page, so only \`${EXPECTED_NOINDEX_FAILURE}\` ` +
`may fail; these also failed: ${unexpected.join(', ')}`,
);
}
}
}
} finally {
// `kill()` is synchronous in chrome-launcher 1.x — `await` on it draws
// ts(80007) from `astro check`, which this repo keeps at zero.
chrome.kill();
chromeA11y.kill();
server.close();
}
const w = Math.max(28, ...rows.map((r) => r.page.length + 2));
console.log(`\n${pad('page', w)} perf a11y* bestp seo LCP CLS`);
console.log('-'.repeat(w + 44));
for (const r of rows) {
const cells = CATEGORIES.map((id) =>
pad(id === 'seo' && r.noindex ? `${r.scores[id]}n` : r.scores[id], 6),
).join(' ');
const lcpCell = pad(`${(r.lcp / 1000).toFixed(2)}s`, 8);
console.log(`${pad(r.page, w)} ${cells} ${lcpCell} ${r.cls.toFixed(3)}`);
}
// The worst-of row excludes noindex pages from the SEO column, because
// including them would report 69 as the site's worst SEO score forever and
// train a reader to ignore the column — which is how a real regression there
// would go unnoticed.
const worst = (id) => {
const relevant = id === 'seo' ? rows.filter((r) => !r.noindex) : rows;
return relevant.length
? Math.min(...relevant.map((r) => r.scores[id]))
: 100;
};
console.log('-'.repeat(w + 44));
console.log(
`${pad(`worst of ${rows.length}`, w)} ` +
CATEGORIES.map((id) => pad(worst(id), 6)).join(' ') +
` ${pad(`${(Math.max(...rows.map((r) => r.lcp)) / 1000).toFixed(2)}s`, 8)} ` +
Math.max(...rows.map((r) => r.cls)).toFixed(3),
);
console.log(
`\nbudgets: all four categories >= ${THRESHOLD} (mobile) · ` +
`LCP < ${LCP_BUDGET_MS / 1000}s · CLS < ${CLS_BUDGET} — docs/04-seo-spec.md`,
);
const noindexCount = rows.filter((r) => r.noindex).length;
if (noindexCount) {
console.log(
`n = deliberately noindex (${noindexCount} page(s)). Lighthouse's SEO\n` +
' category cannot exceed ~69 on such a page: `is-crawlable` fails on the\n' +
' `noindex` the page is supposed to carry. Those pages are asserted audit\n' +
' by audit instead — every SEO audit must pass except that one — and are\n' +
' excluded from the SEO worst-of above.',
);
}
console.log(
'* a11y is measured with prefers-reduced-motion forced. The scroll-driven\n' +
" reveal otherwise puts axe's colour-contrast audit on mid-animation\n" +
' opacity rather than on the palette — 24 false nodes, measured. See the\n' +
' comment on chromeA11y in this script.',
);
// Reported, not asserted. docs/04 states LCP and CLS as budgets; Lighthouse's
// simulated throttling on a loopback server is not the Slow 4G field
// measurement they describe, so a hard failure here would be a claim about
// the instrument. The category scores ARE the gate.
const lcpOver = rows.filter((r) => r.lcp >= LCP_BUDGET_MS);
const clsOver = rows.filter((r) => r.cls >= CLS_BUDGET);
if (lcpOver.length) {
console.log(
`note: LCP at or over budget on ${lcpOver.length} page(s): ` +
lcpOver.map((r) => r.page).join(', '),
);
}
if (clsOver.length) {
console.log(
`note: CLS at or over budget on ${clsOver.length} page(s): ` +
clsOver.map((r) => r.page).join(', '),
);
}
if (breaches.length) {
console.error(`\nBUDGET BREACH — ${breaches.length}:`);
for (const b of breaches) console.error(` - ${b}`);
console.error('\nCLAUDE.md: treat a budget breach as a failing build.');
process.exit(1);
}
console.log(`\nOK — ${rows.length} page(s), no category below ${THRESHOLD}.`);
}
await main();