morphit/apps/web/scripts/brag-list-trailer-invariants-smoke.ts

322 lines
13 KiB
TypeScript

#!/usr/bin/env tsx
/**
* brag-list-trailer-invariants smoke — Part 122 cp75 (LL #75 / O-23).
*
* MORPHIT-BRAG-LIST.md's trailer summarises the file ("288 specific
* selling points... Last updated 2026-05-19"). Three drift classes
* accumulated across cp70→cp74 with no defense to catch them:
*
* 1. Trailer entry-count went stale (claimed 288, actually 299).
* Every checkpoint that inserted a new brag entry forgot to
* bump the trailer. Eleven entries of drift accumulated.
*
* 2. Trailer "Last updated" date went stale (claimed 2026-05-19;
* cp74 work was 2026-05-20). cp74's mediakit regeneration
* touched the file but didn't refresh the trailer date.
*
* 3. Trailer claimed "ADR-0001-*.md through ADR-0036-*.md"
* misleads since 0016 is absent (35 actual ADRs, not 36).
* Adding ADR 0036 didn't trigger a recount of the range claim.
*
* cp60-O12 brag-list-kiss-budget audits PER-ENTRY length but not
* the trailer. cp66-O16 cross-document-value-invariants covers
* config invariants (DB names, ports, CIDR) but not summary-vs-
* content. This smoke fills the gap.
*
* Invariants checked:
* I-1: trailer-count == actual `^N. \*\*` numbered-bold entries
* I-2: trailer "Last updated: <day> <FullMonth>, <year>" date is
* in that verbatim form (ISO YYYY-MM-DD is rejected) AND is
* ≥ the latest ISO date cited in any entry (rejects stale)
* I-3: trailer ADR range claim "ADR-XXXX-*.md through ADR-YYYY-*.md"
* matches max numbered ADR in docs/adr/ (template excluded)
* I-4: no duplicate entry numbers within the body (TOC excluded).
* Catches the cp75-D12 drift class: two checkpoints both
* appending entries that should be numbered N+1 but separately
* picking the same N. Surfaced at cp75 — 6 collisions found
* (155, 156, 236-239), all renumbered to 294-299.
*
* Self-test (M-146):
* - Change trailer "299" → "287" → smoke fires.
* - Change trailer "4 June, 2026" → "1 April, 2026" → smoke fires.
* - Change trailer to ISO "2026-06-04" → smoke fires (not verbatim).
* - Change "0036" → "0099" → smoke fires.
* - Add ADR-0037 without trailer bump → smoke fires.
*
* Recurring class scope progression for trailer/summary discipline:
* cp60-O12: per-entry length budget
* cp66-O16: cross-document config-value invariants
* cp75-O23: document-trailer summary-vs-content (THIS smoke)
*
* Limitation: only checks MORPHIT-BRAG-LIST.md's trailer. Other
* documents (RELEASE-NOTES, README) have their own summary
* sentences but no consistent trailer format yet. If a second
* doc adopts the pattern, extend this smoke or fork.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '..', '..', '..');
let failed = 0;
let passed = 0;
function pass(name: string): void { console.log(`${name}`); passed++; }
function fail(name: string, detail: string): void {
console.error(`${name}`); console.error(` ${detail}`); failed++;
}
console.log('\n── brag-list-trailer-invariants smoke (cp75 LL #75 / O-23) ──\n');
const bragPath = join(REPO_ROOT, 'MORPHIT-BRAG-LIST.md');
const bragSrc = readFileSync(bragPath, 'utf-8');
// ── I-1: trailer entry count vs actual entries ──────────────────
// Count actual `^N. **` numbered-bold entries (the canonical
// brag-entry shape used throughout the file). Markdown soft-wraps
// don't introduce these so the regex is unambiguous.
const entryLines = bragSrc.split('\n').filter((l) => /^\d+\.\s+\*\*/.test(l));
const actualEntries = entryLines.length;
// Find the trailer sentence: "*N specific selling points."
const trailerCountMatch = bragSrc.match(/\*(\d+)\s+specific selling points\./);
if (!trailerCountMatch) {
fail('I-1 trailer count exists', 'No "*N specific selling points." sentence found in trailer.');
} else {
const claimed = parseInt(trailerCountMatch[1]!, 10);
if (claimed !== actualEntries) {
fail(
'I-1 trailer count matches actual entries',
`Trailer claims ${claimed}, file has ${actualEntries} numbered-bold entries. ` +
`Update trailer to "*${actualEntries} specific selling points."`
);
} else {
pass(`I-1 trailer count matches actual entries (${actualEntries})`);
}
}
// ── I-2: trailer "Last updated" date is fresh + in the canonical
// VERBATIM format ────────────────────────────────────────────
// The trailer date is written out verbatim as "<day> <FullMonth>,
// <year>" — day number, full month name, then year — e.g.
// "Last updated: 4 June, 2026." This invariant ENFORCES that
// verbatim format (an ISO "YYYY-MM-DD" trailer fails here, so the
// format cannot silently revert) AND that the date is not stale: it
// must be ≥ the latest ISO date cited anywhere in the file's entry
// text (some entries cite their own dates, e.g. "as of 2026-05-03",
// so the trailer's "Last updated" should be at least as fresh as any
// of them). The verbatim date is normalised to a UTC timestamp for
// that comparison.
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const trailerDateMatch = bragSrc.match(
/Last updated:\s+(\d{1,2})\s+([A-Z][a-z]+),\s+(\d{4})\b/
);
if (!trailerDateMatch) {
fail(
'I-2 trailer date exists in verbatim form',
'No "Last updated: <day> <FullMonth>, <year>" sentence found in trailer ' +
'(e.g. "Last updated: 4 June, 2026."). The date must be written out ' +
'verbatim — day number, full month name, then year — not ISO YYYY-MM-DD.'
);
} else {
const dayNum = parseInt(trailerDateMatch[1]!, 10);
const monthName = trailerDateMatch[2]!;
const yearNum = parseInt(trailerDateMatch[3]!, 10);
const monthIdx = MONTHS.indexOf(monthName);
if (monthIdx < 0) {
fail(
'I-2 trailer month is a full month name',
`Trailer month "${monthName}" is not a full English month name ` +
`(expected one of: ${MONTHS.join(', ')}).`
);
} else if (dayNum < 1 || dayNum > 31) {
fail('I-2 trailer day is valid', `Trailer day "${dayNum}" is out of range 1-31.`);
} else {
const claimedHuman = `${dayNum} ${monthName}, ${yearNum}`;
const claimedTs = Date.UTC(yearNum, monthIdx, dayNum);
// Staleness check: compare to the LATEST ISO date mentioned in
// any entry's text. The trailer itself is no longer ISO, so it
// can never accidentally match this scan.
const allDates = Array.from(bragSrc.matchAll(/\b(20\d{2}-\d{2}-\d{2})\b/g)).map(
(m) => m[1]!
);
let latestOtherDate: string | null = null;
let latestOtherTs = -Infinity;
for (const d of allDates) {
const [y, mo, day] = d.split('-');
const ts = Date.UTC(parseInt(y!, 10), parseInt(mo!, 10) - 1, parseInt(day!, 10));
if (ts > latestOtherTs) {
latestOtherTs = ts;
latestOtherDate = d;
}
}
if (latestOtherDate !== null && latestOtherTs > claimedTs) {
fail(
'I-2 trailer date is fresh',
`Trailer "Last updated: ${claimedHuman}" but file contains newer date ` +
`${latestOtherDate} in entry text. Bump the trailer to at least that date, ` +
`written verbatim (e.g. "Last updated: 3 May, 2026.").`
);
} else {
pass(
`I-2 trailer date is fresh + verbatim ("${claimedHuman}", no newer dates cited in file)`
);
}
}
}
// ── I-3: trailer ADR range claim matches actual ADRs ────────────
// Find a sentence like "ADR-XXXX-*.md through ADR-YYYY-*.md" or
// "docs/adr/XXXX-*.md through docs/adr/YYYY-*.md".
const adrRangeMatch = bragSrc.match(/(?:ADR-|docs\/adr\/|`docs\/adr\/)0*(\d{1,4})-[^\s]*\s+through\s+(?:ADR-|docs\/adr\/|`docs\/adr\/)?0*(\d{1,4})/);
if (!adrRangeMatch) {
// Not present is OK — only check if the claim is made
pass('I-3 trailer ADR-range claim absent or not in standard form (skipped)');
} else {
const lo = parseInt(adrRangeMatch[1]!, 10);
const hi = parseInt(adrRangeMatch[2]!, 10);
const adrDir = join(REPO_ROOT, 'docs/adr');
const adrFiles = readdirSync(adrDir)
.filter((f) => /^\d{4}-/.test(f) && f.endsWith('.md') && !f.includes('template'));
const adrNums = adrFiles
.map((f) => parseInt(f.slice(0, 4), 10))
.filter((n) => Number.isFinite(n) && n > 0);
const minActual = Math.min(...adrNums);
const maxActual = Math.max(...adrNums);
if (lo !== minActual || hi !== maxActual) {
fail(
'I-3 trailer ADR range matches actual ADRs',
`Trailer claims ADR-${String(lo).padStart(4, '0')} through ADR-${String(hi).padStart(4, '0')}; ` +
`actual range is ${String(minActual).padStart(4, '0')}-${String(maxActual).padStart(4, '0')} ` +
`(${adrNums.length} ADRs, template excluded). ` +
`If non-contiguous (e.g. 0016 absent), prose should note that explicitly.`
);
} else {
// Also assert range is contiguous, or call it out
const expected = new Set<number>();
for (let i = lo; i <= hi; i++) expected.add(i);
const actual = new Set(adrNums);
const missing = [...expected].filter((n) => !actual.has(n));
if (missing.length > 0) {
// The range bounds are right but there are gaps — claim should mention
pass(
`I-3 trailer ADR range bounds match (${lo}-${hi}); ${missing.length} numbers absent in range ` +
`(${missing.map((n) => String(n).padStart(4, '0')).join(', ')}). Range-claim is bounds-only; OK.`
);
} else {
pass(`I-3 trailer ADR range matches actual ADRs (${lo}-${hi}, contiguous, ${adrNums.length} files)`);
}
}
}
// ── I-4: no duplicate entry numbers in body ─────────────────────
// Walk all `^N. **` lines AFTER the table of contents (which itself
// numbers 1-18 for section headers). The body's TOC-vs-content
// boundary is the line that begins with `---` followed by `## 1. `.
// Simpler heuristic: skip any line above the first occurrence of a
// `^1\.` line whose context is in a section header — we instead
// identify the TOC region as the contiguous run of `^N\.` lines
// from the top until a non-`^N\.` line breaks the run (line 12-30
// at cp75). After that point, all `^N. **` entries are body items
// that should be globally-unique.
//
// Concrete: ignore everything before the first `^## 1\.` header
// line (that's the start of body content).
const lines = bragSrc.split('\n');
let bodyStart = 0;
for (let i = 0; i < lines.length; i++) {
if (/^## 1\. /.test(lines[i]!)) {
bodyStart = i;
break;
}
}
// Also stop before the "How to verify any of the above" section
// (the trailer counts and ADR-range claim live there, not entries).
let bodyEnd = lines.length;
for (let i = bodyStart; i < lines.length; i++) {
if (/^## How to verify/.test(lines[i]!)) {
bodyEnd = i;
break;
}
}
const numToLines = new Map<number, number[]>();
for (let i = bodyStart; i < bodyEnd; i++) {
const m = lines[i]!.match(/^(\d+)\.\s+\*\*/);
if (m) {
const n = parseInt(m[1]!, 10);
const list = numToLines.get(n) ?? [];
list.push(i + 1); // 1-indexed for reporting
numToLines.set(n, list);
}
}
const dups = [...numToLines.entries()].filter(([, ls]) => ls.length > 1);
if (dups.length === 0) {
pass(`I-4 no duplicate entry numbers in body (${numToLines.size} unique entries)`);
} else {
const summary = dups
.sort((a, b) => a[0] - b[0])
.map(([n, ls]) => `#${n} @ lines ${ls.join(', ')}`)
.join('; ');
fail(
'I-4 no duplicate entry numbers in body',
`${dups.length} collisions: ${summary}. ` +
`Each entry number must be globally unique; renumber the later occurrences to extend the sequence.`
);
}
// ─── I-5 entries appear in strictly ascending sequential order
// 1, 2, 3, ..., N (no gaps, no jumps, no out-of-order). cp76
// surfaced "entries 319-322 inserted in the middle of section
// 3 with original numbers"; renumber + this invariant locks
// the discipline so any future drift fails CI. ──
{
const entryNumbersInOrder: { n: number; line: number }[] = [];
for (let i = bodyStart; i < bodyEnd; i++) {
const m = lines[i]!.match(/^(\d+)\.\s+\*\*/);
if (m) {
entryNumbersInOrder.push({ n: parseInt(m[1]!, 10), line: i + 1 });
}
}
const outOfOrder: { from: number; to: number; line: number }[] = [];
for (let i = 0; i < entryNumbersInOrder.length; i++) {
const expected = i + 1;
if (entryNumbersInOrder[i]!.n !== expected) {
outOfOrder.push({
from: expected,
to: entryNumbersInOrder[i]!.n,
line: entryNumbersInOrder[i]!.line
});
if (outOfOrder.length >= 8) break;
}
}
if (outOfOrder.length === 0) {
pass(
`I-5 entries are sequentially ordered 1..${entryNumbersInOrder.length} (no gaps, no jumps)`
);
} else {
const detail = outOfOrder
.map((o) => `expected #${o.from}, found #${o.to} @ line ${o.line}`)
.join('; ');
fail(
'I-5 entries are sequentially ordered 1..N',
`First ${outOfOrder.length} divergences: ${detail}. ` +
`Renumber the body to be sequential. The renumber script is in scripts/renumber-brag-list.py if you need it.`
);
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nbrag-list-trailer-invariants smoke FAILED');
console.error('Trailer summary drifted from file content. Update MORPHIT-BRAG-LIST.md trailer to reflect reality.');
process.exit(1);
}
console.log(`✓ all ${total} brag-list-trailer-invariants scenarios passed`);