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
338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Proves the Open Graph cards, two ways. `npm run og:proof`, after a build.
|
||
*
|
||
* WHY THIS EXISTS AT ALL. `AGENTS.md` R15: *"Nobody on this project will ever
|
||
* see the defect. A link preview is rendered by LinkedIn, Slack and Teams for a
|
||
* reader who is not us."* Generating the cards does not fix that — it moves the
|
||
* invisible thing from "wrong image" to "wrong image, generated". So the two
|
||
* failures that would stay invisible are checked mechanically:
|
||
*
|
||
* 1. **Every page's `og:image` resolves to a file that exists in `dist/`.** A
|
||
* 404 preview image renders as a blank card, and nothing else in this repo
|
||
* would notice. Checked by reading the built HTML, not the source.
|
||
*
|
||
* 2. **Every card's headline and eyebrow are its page's own `<h1>` and first
|
||
* `.eyebrow`, character for character.** This is the compliance half. Text
|
||
* baked into a JPEG is text `npm run check:claims` cannot grep, and under D20
|
||
* that script is the only per-step claims control there is — so a card must
|
||
* never carry a claim its page does not already make in auditable HTML. The
|
||
* check enforces that structurally rather than trusting an author to
|
||
* remember it, and it fails in both directions: editing the page without the
|
||
* registry, or the registry without the page.
|
||
*
|
||
* It also catches the quiet one: a straight apostrophe in the registry
|
||
* against the typographic apostrophe the page renders. Found exactly that on
|
||
* the first run, on `/practice/insurance/`.
|
||
*
|
||
* It reads `src/data/og-cards.ts` DIRECTLY — Node strips the types — so there is
|
||
* no second list of cards to keep in step with the first.
|
||
*
|
||
* Optional: `npm run og:proof -- --sheet` writes a contact sheet of every card
|
||
* to `dist/og-proof.jpg` so the set can be looked at in one go. Not part of the
|
||
* check; a human still has to look.
|
||
*/
|
||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||
import { join, relative, sep } from 'node:path';
|
||
import sharp from 'sharp';
|
||
import {
|
||
OG_CARDS,
|
||
PORTRAIT_PAGES,
|
||
articleCard,
|
||
ogCardPath,
|
||
} from '../src/data/og-cards.ts';
|
||
|
||
const ROOT = process.cwd();
|
||
const DIST = join(ROOT, 'dist');
|
||
const SITE = 'https://adr.smlcompany.ca';
|
||
|
||
const strip = (html) =>
|
||
html
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/ /g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
async function pages(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 pages(full)));
|
||
else if (entry.name === 'index.html') {
|
||
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||
out.push({ path: rel ? `/${rel}/` : '/', file: full });
|
||
}
|
||
/* ⚠️ `index.html` ALONE MISSED A WHOLE PAGE. `build.format: 'directory'`
|
||
puts every route at `<dir>/index.html` — except the ones Astro emits
|
||
outside the convention, and `404.astro` becomes `dist/404.html`. So this
|
||
script enumerated 22 pages of 23, and the symptom was backwards: it
|
||
reported the 404 page's card as ORPHANED ("generated, but no built page
|
||
references it") rather than reporting the page as unchecked. `path` here
|
||
is an `OG_CARDS` key, which is `Astro.url.pathname` — `/404/`, not
|
||
`/404.html`. `scripts/lighthouse.mjs` had the same blind spot and needs
|
||
the URL form instead; see the note there. */
|
||
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||
out.push({ path: `/${entry.name.replace(/\.html$/, '')}/`, file: full });
|
||
}
|
||
}
|
||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||
}
|
||
|
||
const problems = [];
|
||
const fail = (msg) => problems.push(msg);
|
||
|
||
try {
|
||
await stat(join(DIST, 'index.html'));
|
||
} catch {
|
||
console.error('dist/ is missing or empty. Run `npm run build` first.');
|
||
process.exit(2);
|
||
}
|
||
|
||
const built = await pages();
|
||
const seenCards = new Set();
|
||
let checkedHeadlines = 0;
|
||
let checkedArticles = 0;
|
||
|
||
/**
|
||
* An article's expected card, from the SAME `articleCard()` the endpoint calls,
|
||
* given the title in that article's own frontmatter. Read from the `.mdx` rather
|
||
* than from the built page, so the comparison has two independent sides: what
|
||
* the article says its title is, and what the route rendered as the `<h1>`.
|
||
*/
|
||
async function expectedArticleCard(path) {
|
||
const slug = path.replace(/^\/insights\/|\/$/g, '');
|
||
for (const ext of ['mdx', 'md']) {
|
||
try {
|
||
const src = await readFile(
|
||
join(ROOT, 'src', 'content', 'insights', `${slug}.${ext}`),
|
||
'utf8',
|
||
);
|
||
const m = /^title:\s*(.*)$/m.exec(src);
|
||
if (!m) break;
|
||
let title = m[1].trim();
|
||
// YAML scalar: strip one layer of quoting and unescape a doubled single
|
||
// quote, which is how YAML writes a literal apostrophe inside '…'.
|
||
if (
|
||
(title.startsWith("'") && title.endsWith("'")) ||
|
||
(title.startsWith('"') && title.endsWith('"'))
|
||
) {
|
||
title = title.slice(1, -1);
|
||
}
|
||
title = title.replace(/''/g, "'");
|
||
return articleCard(title);
|
||
} catch {
|
||
/* try the next extension */
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
for (const { path, file } of built) {
|
||
const html = await readFile(file, 'utf8');
|
||
|
||
// ---- 1. og:image exists -------------------------------------------------
|
||
const og = /<meta property="og:image" content="([^"]+)"/.exec(html);
|
||
if (!og) {
|
||
fail(`${path}: no og:image meta tag at all`);
|
||
continue;
|
||
}
|
||
const url = og[1];
|
||
if (!url.startsWith(SITE + '/')) {
|
||
fail(`${path}: og:image is not an absolute URL on ${SITE} — ${url}`);
|
||
continue;
|
||
}
|
||
const assetPath = url.slice(SITE.length);
|
||
try {
|
||
await stat(join(DIST, assetPath));
|
||
} catch {
|
||
fail(`${path}: og:image points at ${assetPath}, which is not in dist/`);
|
||
continue;
|
||
}
|
||
seenCards.add(assetPath);
|
||
|
||
// ---- 2. card copy is the page's own copy --------------------------------
|
||
const isPortrait = PORTRAIT_PAGES.includes(path);
|
||
const card = OG_CARDS[path];
|
||
|
||
if (isPortrait) {
|
||
if (card) fail(`${path}: in PORTRAIT_PAGES and in OG_CARDS — pick one`);
|
||
if (assetPath.startsWith('/og/')) {
|
||
fail(`${path}: is a portrait page but its og:image is a generated card`);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const expected = ogCardPath(path);
|
||
if (assetPath !== expected) {
|
||
fail(`${path}: og:image is ${assetPath}, expected ${expected}`);
|
||
}
|
||
|
||
/**
|
||
* ⚠️ AN ARTICLE IS CHECKED THE SAME WAY AS A REGISTRY PAGE, AND UNTIL
|
||
* 2026-08-31 IT WAS NOT CHECKED AT ALL.
|
||
*
|
||
* The first version of this script matched an article's card FILENAME and then
|
||
* `continue`d — skipping the headline and eyebrow comparisons entirely. So the
|
||
* one surface `check:claims` cannot reach was also the one surface this script
|
||
* did not compare, which is the opposite of what its own header claims and what
|
||
* `docs/04` says it enforces.
|
||
*
|
||
* `adversarial-reviewer` proved it rather than arguing it: with
|
||
* `headline: 'DELIBERATELY WRONG CARD TEXT — probe'` set in the endpoint and one
|
||
* article published, the card rendered that sentence in 68px Instrument Serif
|
||
* and this script printed `OK — every og:image resolves, and no card asserts
|
||
* anything its page does not`, exit 0. **`checkedHeadlines` stayed pinned at the
|
||
* registry size** no matter how many articles published — a coverage number
|
||
* that reads like completeness and falls further behind as the site grows,
|
||
* which is exactly the uniform-pass shape `CLAUDE.md` warns is the dangerous
|
||
* half.
|
||
*
|
||
* An article has no registry entry by design — its card comes from the
|
||
* collection — so the expectation comes from the page instead: the endpoint
|
||
* sets an article card's headline to `entry.data.title`, which is also the
|
||
* page's `<h1>`. Comparing the card's source of truth against the rendered
|
||
* `<h1>` is therefore the same check, and the eyebrow is the literal the
|
||
* endpoint sets.
|
||
*/
|
||
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
|
||
if (!card && !isArticle) {
|
||
fail(`${path}: built, not a portrait page, and has no OG_CARDS entry`);
|
||
continue;
|
||
}
|
||
let expected_card = card;
|
||
if (!expected_card) {
|
||
expected_card = await expectedArticleCard(path);
|
||
if (!expected_card) {
|
||
fail(
|
||
`${path}: could not read a \`title:\` from this article's own .mdx, so ` +
|
||
'its card cannot be compared against anything. That is a failure, not ' +
|
||
'a skip — an unchecked card is the one surface check:claims cannot see.',
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
const expectedEyebrow = expected_card.eyebrow;
|
||
|
||
const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/.exec(html);
|
||
if (!h1) {
|
||
fail(`${path}: no <h1> to compare the card headline against`);
|
||
} else {
|
||
const text = strip(h1[1]);
|
||
const expectedHeadline = expected_card.headline;
|
||
if (text !== expectedHeadline) {
|
||
fail(
|
||
`${path}: card headline is not the page's <h1>.\n` +
|
||
` <h1>: ${JSON.stringify(text)}\n` +
|
||
` card: ${JSON.stringify(expectedHeadline)}`,
|
||
);
|
||
} else {
|
||
checkedHeadlines += 1;
|
||
if (isArticle) checkedArticles += 1;
|
||
}
|
||
}
|
||
|
||
const eyebrow = /<p class="eyebrow"[^>]*>([\s\S]*?)<\/p>/.exec(html);
|
||
if (!eyebrow) {
|
||
fail(`${path}: no .eyebrow to compare the card eyebrow against`);
|
||
} else {
|
||
const text = strip(eyebrow[1]);
|
||
if (text !== expectedEyebrow) {
|
||
fail(
|
||
`${path}: card eyebrow is not the page's first .eyebrow.\n` +
|
||
` page: ${JSON.stringify(text)}\n` +
|
||
` card: ${JSON.stringify(expectedEyebrow)}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ⚠️ AND THE COVERAGE IS ASSERTED, NOT REPORTED. Printing "20 headlines matched"
|
||
* beside a growing site is how the gap above stayed invisible: the number went up
|
||
* and never went up ENOUGH, and nothing said so. Every built page except the
|
||
* portrait pages carries a generated card, so the count must equal that or a page
|
||
* was silently skipped.
|
||
*/
|
||
const shouldCheck = built.filter(
|
||
({ path }) => !PORTRAIT_PAGES.includes(path),
|
||
).length;
|
||
if (checkedHeadlines !== shouldCheck) {
|
||
fail(
|
||
`only ${checkedHeadlines} of ${shouldCheck} non-portrait pages had their ` +
|
||
'card headline compared against their <h1>. A page was skipped, which is ' +
|
||
'the failure this assertion exists to make loud.',
|
||
);
|
||
}
|
||
|
||
// ---- 3. no card generated for a page that does not exist -------------------
|
||
// A stray card is not a shipped defect, but it is the signature of a page that
|
||
// was renamed or removed and a registry entry that was not — which the next
|
||
// person reads as "the card exists, so the page must".
|
||
let strays = [];
|
||
try {
|
||
const files = await readdir(join(DIST, 'og'));
|
||
strays = files
|
||
.filter((f) => f.endsWith('.jpg'))
|
||
.map((f) => `/og/${f}`)
|
||
.filter((p) => !seenCards.has(p));
|
||
} catch {
|
||
fail('dist/og/ does not exist — no cards were generated');
|
||
}
|
||
for (const s of strays) {
|
||
fail(`${s}: generated, but no built page references it`);
|
||
}
|
||
|
||
// ---- optional contact sheet ----------------------------------------------
|
||
if (process.argv.includes('--sheet')) {
|
||
const files = (await readdir(join(DIST, 'og')))
|
||
.filter((f) => f.endsWith('.jpg'))
|
||
.sort();
|
||
const COLS = 3;
|
||
const W = 400;
|
||
const H = 210;
|
||
const rows = Math.ceil(files.length / COLS);
|
||
const tiles = await Promise.all(
|
||
files.map(async (f, i) => ({
|
||
input: await sharp(join(DIST, 'og', f))
|
||
.resize(W, H)
|
||
.toBuffer(),
|
||
left: (i % COLS) * W,
|
||
top: Math.floor(i / COLS) * H,
|
||
})),
|
||
);
|
||
const sheet = await sharp({
|
||
create: {
|
||
width: COLS * W,
|
||
height: rows * H,
|
||
channels: 3,
|
||
background: '#ffffff',
|
||
},
|
||
})
|
||
.composite(tiles)
|
||
.jpeg({ quality: 82 })
|
||
.toBuffer();
|
||
await writeFile(join(DIST, 'og-proof.jpg'), sheet);
|
||
console.log(
|
||
`contact sheet: dist/og-proof.jpg — ${files.length} cards, ${COLS}x${rows}`,
|
||
);
|
||
}
|
||
|
||
console.log(
|
||
`og:proof — ${built.length} built pages, ${seenCards.size} distinct og:image ` +
|
||
`targets, ${checkedHeadlines} card headlines matched their page <h1> ` +
|
||
`(${checkedArticles} of them articles).`,
|
||
);
|
||
if (problems.length) {
|
||
console.error(`\nOG CARD PROBLEMS — ${problems.length}:`);
|
||
for (const p of problems) console.error(` - ${p}`);
|
||
process.exit(1);
|
||
}
|
||
console.log(
|
||
'OK — every og:image resolves, and no card asserts anything its page does not.',
|
||
);
|