Build and deploy / build-and-deploy (push) Failing after 4s
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
388 lines
16 KiB
JavaScript
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();
|