morphit/scripts/brag-list-claim-parity-smoke.ts

729 lines
24 KiB
TypeScript

#!/usr/bin/env tsx
/**
* scripts/brag-list-claim-parity-smoke.ts
*
* Structural Defense #38 — brag-list claim parity (cp111).
*
* MORPHIT-BRAG-LIST.md is public-facing marketing copy whose
* load-bearing rule (memory #15) is "every claim is verifiable
* in code or honestly disclosed as backlog." Without a smoke,
* the brag list silently rots: code paths get renamed, env-vars
* are removed, op IDs drift, and asset/locale/ADR counts go
* stale without anyone noticing. cp111 found three real drift
* cases that motivated this smoke:
*
* 1. `RELEASE-NOTES-v1.0.0-beta.1.md` claimed "3,924
* self-checking smoke scenarios" while the actual battery
* had moved to 4432. Not in the brag list itself, but
* same class — stale specific number in a marketing-copy
* surface.
* 2. `docs/AUDIT-2026-05-FINAL-REPORT.md` claimed CI runs
* `npm run check` for the frontend. CI actually invokes
* the typecheck through `workspace-typecheck-smoke`
* indirectly — the literal `npm run check` step doesn't
* exist in any workflow.
* 3. `TARBALL.md`'s handoff section listed three pre-launch
* operator-actions as "still open" that have actually
* been closed for many checkpoints.
*
* cp111 fixes all three by hand; this smoke catches the
* fourth, fifth, and Nth recurrences automatically.
*
* What this smoke checks (each tuple counts as one scenario):
*
* A. **File-path references.** Every backtick-quoted token
* starting with `scripts/`, `apps/`, `ops/`, `packages/`,
* or `docs/` and either ending in a known file extension
* OR ending in `/` (directory reference) must resolve on
* disk. Catches renames + deletions.
*
* B. **Custom-JSON op references.** Every backtick-quoted
* `morphit_<name>_v<N>` token must appear at least once
* in source code under `apps/indexer/src/`, `apps/relay/`,
* or `apps/web/src/` — the three places a real op ID
* would be implemented or referenced. Catches op-name
* drift (e.g. `morphit_release_v1` rename).
*
* C. **Operator env-var references.** Every backtick-quoted
* `MORPHIT_<NAME>` token must appear at least once in
* source code under `apps/`, `packages/`, `ops/`, or
* `.env.example` files. Catches env-var renames /
* deletions.
*
* D. **Asset-count anchor.** Any prose claim of the form
* "N tradable assets" (where N is a number or spelled-out
* English word from one to twenty) must match
* `ASSET_TICKERS.length` from
* `packages/asset-registry/src/index.ts` — the canonical
* source-of-truth. Catches the recurring drift caught
* manually at cp35 (8 stale "10 tradable" sites) and
* again at cp48 (3 stale "12 tradable" sites).
*
* E. **Locale-count anchor.** Any prose claim of "N locales"
* or "N languages" (where N is a number 5-20 or the
* spelled-out English word) must match the count of
* `apps/web/src/lib/i18n/locales/*.json`.
*
* F. **ADR-count anchor.** Any prose claim of "N ADRs" or
* "N architecture decision records" must match the count
* of `docs/adr/00*.md` files minus the template
* (`0000-template.md`).
*
* G. **Footer brag-entry count.** The footer string
* "*N specific selling points*" must match the count of
* numbered top-level entries in the brag list
* (lines matching `^[0-9]+\.\s+\*\*`).
*
* H. **ADR filename-range anchor.** Any prose claim of the
* form "0001-… through NNNN-…" or "docs/adr/0001-foo
* through 0042-bar.md" must name the highest non-template
* ADR number on disk as the upper bound. Catches the
* drift caught manually at cp131 (README.md was stale at
* "through 0036-…" when the disk had 0042-…).
*
* Scope: this smoke covers MORPHIT-BRAG-LIST.md, README.md,
* and RELEASE-NOTES-v*.md — the three "marketing-class"
* surfaces where stale specific claims hurt most. ADRs,
* audit logs, REVISIT-LIST, TARBALL, and PHASE-* docs are
* deliberately out of scope (they're historical / journal,
* subject to the cp82 annotation-not-rewrite rule).
*
* False-positive avoidance:
*
* - URL strings (`https://...`) are excluded from path checks.
* - Capability-named env vars from third-party docs
* (`MORPHIT_OTEL_TRACES_ENDPOINT` if it doesn't exist) are
* checked against actual code, not against a hand-curated
* allowlist — if the brag list claims an env var, it MUST
* exist in code. No allowlist exemptions.
* - Numeric-anchor regexes are narrowly scoped to a leading
* digit/word + the exact noun ("tradable assets", "locales",
* "languages", "ADRs", "architecture decision records"),
* so a sentence like "100% of fees" doesn't get parsed as
* "100 of fees."
*
* Each (doc, line, claim) tuple counts as one scenario. The
* smoke is allowed to find zero failures — that's the green
* state — but MUST find a minimum-floor number of scenarios
* (~150 currently) or the regexes are silently broken and
* pass-zero is masking real drift.
*/
import {
readFileSync,
existsSync,
readdirSync,
statSync
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..');
// Marketing-class docs in scope. Each is the kind of surface a
// reader uses to decide whether to trust the project; stale
// specifics damage trust more here than in journal-style docs.
//
// NOTE: published `RELEASE-NOTES-v*.md` files are intentionally
// NOT policed. They describe a specific shipped release and are
// frozen at publish-time — counting them against the current
// canonical source would either force lies (rewrite the file to
// match today's state) or trigger false failures (correctly
// describing a past release that had different counts). When a
// new release is being prepared, drop its in-progress notes file
// into this list temporarily so claim-parity catches drift before
// publish, then remove it again once the release ships.
const MARKETING_DOCS = [
'MORPHIT-BRAG-LIST.md',
'README.md',
// llms.txt is the public-facing policy/index file served at
// /llms.txt for AI agents and retrieval crawlers. Same trust-
// damage class as the brag list — wrong asset/locale/ADR
// counts here mislead a uniquely-influential audience (AI
// training pipelines that may ingest stale claims at scale).
'apps/web/static/llms.txt'
];
// --- (A) file-path references --------------------------------
// Backtick-quoted path: <root>/<rest>, where rest can end in a
// file extension OR in a trailing slash (directory).
const PATH_TOKEN_RE =
/`((?:scripts|apps|ops|packages|docs)\/[A-Za-z0-9_./-]+)`/g;
const VALID_EXTS = new Set([
'sh',
'ts',
'tsx',
'js',
'mjs',
'cjs',
'py',
'sql',
'yml',
'yaml',
'json',
'md',
'service',
'timer',
'conf',
'env',
'example',
'asc',
'svg',
'png',
'zip',
'svelte',
'css',
'html'
]);
function isVerifiablePath(p: string): boolean {
// Trailing punctuation that snuck in
if (/[.,:;]$/.test(p)) return false;
// Glob / template / placeholder
if (/[*[\]<>{}]/.test(p)) return false;
// URL — must not appear via the regex (no `://` in match),
// but defensive
if (p.includes('://')) return false;
// node_modules
if (p.includes('node_modules/')) return false;
// Bare directory (ends with /) — verified directly
if (p.endsWith('/')) return true;
// File — must have a known extension
const lastDot = p.lastIndexOf('.');
if (lastDot === -1) return false;
const ext = p.slice(lastDot + 1).toLowerCase();
return VALID_EXTS.has(ext);
}
// --- (B) custom-JSON op references ---------------------------
const OP_TOKEN_RE = /`(morphit_[a-z][a-z0-9_]*_v\d+)`/g;
// Cache: walk apps/indexer/src + apps/relay + apps/web/src
// once, build a single big string we can substring-search.
const OP_SCAN_ROOTS = [
'apps/indexer/src',
'apps/relay/src',
'apps/web/src/lib'
];
// --- (C) operator env-var references -------------------------
// Env-var token: bare backtick-quoted name, optionally
// followed by `=value` shell-assignment syntax (the brag list
// uses both forms — `MORPHIT_X` and `MORPHIT_X=` — and both
// should resolve the same way).
const ENV_TOKEN_RE = /`(MORPHIT_[A-Z][A-Z0-9_]*)(?:=[^`]*)?`/g;
const ENV_SCAN_ROOTS = [
'apps/indexer/src',
'apps/relay/src',
'apps/web/src/lib',
'apps/ops-cli/src',
'apps/matrix-bot/src',
'packages',
'ops/env',
'ops/ansible',
'ops/bunkerweb',
'ops/scripts'
];
// --- (D, E, F) numeric anchors -------------------------------
const NUMBER_WORDS: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20
};
function parseNumberToken(t: string): number | null {
const lc = t.toLowerCase().replace(/,/g, '');
if (/^\d+$/.test(lc)) return parseInt(lc, 10);
if (Object.prototype.hasOwnProperty.call(NUMBER_WORDS, lc)) {
return NUMBER_WORDS[lc];
}
return null;
}
// Build the regex piece for either a digit or a spelled-out word.
const NUM = '(\\d{1,3}|[A-Za-z]{3,9})';
// (D) tradable assets — exact phrase "tradable assets" right
// after the number. Avoid matching "asset" (singular) so
// "an asset Morphit trades" doesn't trip.
const ASSET_RE = new RegExp(`${NUM}\\s+(?:tradable\\s+assets)\\b`, 'gi');
// (E) locales / languages — same pattern; both nouns accepted.
const LOCALE_RE = new RegExp(
`${NUM}\\s+(locales?|languages?)\\b`,
'gi'
);
// Words that, when they appear on the same line as a
// non-canonical locale/language number, signal "this is a
// subset reference, not a total claim". Real-world false-
// positive seen at cp111 first-run: brag entry 161 reads
// "across all 6 locales" referring back to the 6 community-
// translation backlog locales mentioned earlier in the same
// sentence. Strict literal regex can't tell that apart from
// drift, so we suppress when the line names a known subset.
//
// Allowlisting only kicks in when n !== CANONICAL_LOCALES;
// if n DOES equal canonical, the claim is treated as a
// total reference regardless of surrounding language (an
// accurate claim is never drift).
const LOCALE_SUBSET_MARKERS = [
'backlog',
'subset',
'non-EN',
'non-en',
'native',
'core',
'community-translation',
'remaining',
'originally',
'previously'
];
// (F) ADRs — both "ADRs" and the spelled-out "architecture
// decision records". Both forms appear in the brag list.
const ADR_RE = new RegExp(
`${NUM}\\s+(ADRs|architecture\\s+decision\\s+records)\\b`,
'gi'
);
// (G) footer brag-entry count: "*N specific selling points*"
const FOOTER_RE = /\*(\d+)\s+specific\s+selling\s+points/g;
// (H) ADR filename-range claim: catches "0001-… through 0036-…",
// "docs/adr/0001- through 0042-", "ADRs 0001-foo through
// 0042-bar.md", etc. The captured group is the ENDING ADR
// number; it MUST equal the highest non-template ADR number on
// disk. Without this, README.md / brag-list claims like "ADRs
// 0001-… through 0036-…" silently drift as new ADRs land.
//
// Shape captured (whitespace/backticks tolerated between tokens
// because markdown commonly wraps the range tokens in backticks
// — e.g. ``\`0001-…\` through \`0042-…\``):
// - optional `docs/adr/` prefix
// - 4-digit ADR number (start of range)
// - hyphen + filename body (either "…" placeholder or
// `[a-z0-9-]+\.md`)
// - " through " keyword (with optional backticks/whitespace)
// - optional `docs/adr/` prefix
// - 4-digit ADR number (END of range) <- CAPTURED
// - hyphen + filename body
const ADR_RANGE_RE =
/(?:docs\/adr\/)?(\d{4})-(?:…|[a-z0-9-]+\.md)[\s`]*through[\s`]*(?:docs\/adr\/)?(\d{4})-(?:…|[a-z0-9-]+(?:\.md)?)/g;
// ─────────────────────────────────────────────────────────────
interface Scenario {
doc: string;
line: number;
// cp474 — 'H' (ADR filename-range claims, cp131 MED-004) was wired into
// KIND_LABEL and the report loop but never added to this union.
kind: 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
claim: string;
failure?: string;
}
const scenarios: Scenario[] = [];
function listFilesRec(root: string, out: string[] = []): string[] {
let entries: string[];
try {
entries = readdirSync(root);
} catch {
return out;
}
for (const e of entries) {
if (e === 'node_modules' || e === '.svelte-kit' || e === 'dist' || e === 'build') {
continue;
}
const full = join(root, e);
let s;
try {
s = statSync(full);
} catch {
continue;
}
if (s.isDirectory()) {
listFilesRec(full, out);
} else if (
/\.(ts|tsx|js|mjs|cjs|svelte|json|yml|yaml|sql|sh|md|env|example|service|timer)$/.test(
e
)
) {
out.push(full);
}
}
return out;
}
// One-time scan: concatenate all source files under each scan
// root into a single big string for substring lookups. Cheaper
// than re-reading per claim and avoids the false-negative class
// where a token appears in two files but only one is searched.
function buildHaystack(roots: string[]): string {
const parts: string[] = [];
for (const r of roots) {
const root = join(REPO, r);
if (!existsSync(root)) continue;
const s = statSync(root);
const files = s.isDirectory() ? listFilesRec(root) : [root];
for (const f of files) {
try {
parts.push(readFileSync(f, 'utf8'));
} catch {
// unreadable file — skip silently
}
}
}
return parts.join('\n');
}
const OP_HAYSTACK = buildHaystack(OP_SCAN_ROOTS);
const ENV_HAYSTACK = buildHaystack(ENV_SCAN_ROOTS);
// Canonical source-of-truth values for numeric anchors
function countAssetTickers(): number {
const src = readFileSync(
join(REPO, 'packages/asset-registry/src/index.ts'),
'utf8'
);
const m = src.match(/ASSET_TICKERS\s*=\s*\[([^\]]+)\]/);
if (!m) return -1;
// cp425 — the brag list's "N tradable assets" claims count CRYPTO assets
// (the coins a wallet would integrate). Goods assets (BARTER) are orderable
// but are not coins, so they're excluded from this marketing count —
// mirroring isGoodsAsset() in the registry and the GOODS_TICKERS exclusion
// in scripts/build-sitemap.mjs. Keep these in sync when a goods asset lands.
const GOODS_TICKERS = new Set(['BARTER']);
const tickers = m[1]
.split(',')
.map((s) => s.trim().replace(/['"]/g, ''))
.filter((s) => s.length > 0)
.filter((s) => !GOODS_TICKERS.has(s));
return tickers.length;
}
function countLocales(): number {
const dir = join(REPO, 'apps/web/src/lib/i18n/locales');
return readdirSync(dir).filter((f) => f.endsWith('.json')).length;
}
function countAdrs(): number {
const dir = join(REPO, 'docs/adr');
return readdirSync(dir).filter(
(f) => /^\d{4}-/.test(f) && f !== '0000-template.md'
).length;
}
/** Highest 4-digit prefix among non-template ADR filenames.
* Used by claim-class (H): claims like "ADRs 0001-… through
* 0036-…" must name THIS number as the upper bound or be
* flagged as stale. Counts disjoint from countAdrs() because
* numbering can be sparse (retracted ADRs, e.g. 0016, leave a
* gap — the highest number still climbs). */
function highestAdrNumber(): number {
const dir = join(REPO, 'docs/adr');
let max = 0;
for (const f of readdirSync(dir)) {
const m = /^(\d{4})-/.exec(f);
if (!m || f === '0000-template.md') continue;
const n = parseInt(m[1]!, 10);
if (n > max) max = n;
}
return max;
}
function countBragEntries(): number {
const src = readFileSync(join(REPO, 'MORPHIT-BRAG-LIST.md'), 'utf8');
const lines = src.split('\n');
let n = 0;
for (const ln of lines) {
if (/^\d+\.\s+\*\*/.test(ln)) n++;
}
return n;
}
const CANONICAL_ASSETS = countAssetTickers();
const CANONICAL_LOCALES = countLocales();
const CANONICAL_ADRS = countAdrs();
const CANONICAL_ADR_MAX = highestAdrNumber();
const CANONICAL_BRAG_ENTRIES = countBragEntries();
if (CANONICAL_ASSETS < 1) {
console.error(' ✗ could not parse ASSET_TICKERS from asset-registry');
process.exit(1);
}
// Whitelist of paths that may legitimately not exist on disk
// because they represent operator-installation artifacts. Same
// reasoning as operator-doc-fenced-path-existence-smoke.ts:
// `.env` files materialize on the operator's box from the
// `.env.example` templates in the repo.
function isOperatorManaged(p: string): boolean {
if (p.endsWith('.env')) {
return existsSync(join(REPO, p + '.example'));
}
if (p.endsWith('/keystore.json') || p.endsWith('/keystore.wif')) {
return true;
}
return false;
}
console.log('\n── brag-list-claim-parity smoke (cp111) ──────────────\n');
console.log(` canonical: ${CANONICAL_ASSETS} assets · ${CANONICAL_LOCALES} locales · ${CANONICAL_ADRS} ADRs (max #${CANONICAL_ADR_MAX}) · ${CANONICAL_BRAG_ENTRIES} brag entries`);
for (const docRel of MARKETING_DOCS) {
const abs = join(REPO, docRel);
if (!existsSync(abs)) {
console.log(`${docRel} missing — skipping`);
continue;
}
const src = readFileSync(abs, 'utf8');
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
// (A) paths
PATH_TOKEN_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PATH_TOKEN_RE.exec(ln)) !== null) {
const p = m[1];
if (!isVerifiablePath(p)) continue;
if (isOperatorManaged(p)) continue;
const exists = existsSync(join(REPO, p));
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'A',
claim: p
};
if (!exists) {
sc.failure = `path \`${p}\` does not exist`;
}
scenarios.push(sc);
}
// (B) custom-JSON op IDs
OP_TOKEN_RE.lastIndex = 0;
while ((m = OP_TOKEN_RE.exec(ln)) !== null) {
const op = m[1];
const found = OP_HAYSTACK.includes(op);
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'B',
claim: op
};
if (!found) {
sc.failure = `op \`${op}\` not found anywhere under [${OP_SCAN_ROOTS.join(', ')}]`;
}
scenarios.push(sc);
}
// (C) env-var names
ENV_TOKEN_RE.lastIndex = 0;
while ((m = ENV_TOKEN_RE.exec(ln)) !== null) {
const ev = m[1];
const found = ENV_HAYSTACK.includes(ev);
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'C',
claim: ev
};
if (!found) {
sc.failure = `env-var \`${ev}\` not found anywhere under [${ENV_SCAN_ROOTS.join(', ')}]`;
}
scenarios.push(sc);
}
// (D) tradable assets count
ASSET_RE.lastIndex = 0;
while ((m = ASSET_RE.exec(ln)) !== null) {
const n = parseNumberToken(m[1]);
if (n === null) continue; // word we don't recognize — skip
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'D',
claim: `${m[1]} tradable assets`
};
if (n !== CANONICAL_ASSETS) {
sc.failure = `claims ${n} tradable assets but registry has ${CANONICAL_ASSETS}`;
}
scenarios.push(sc);
}
// (E) locales / languages count
LOCALE_RE.lastIndex = 0;
while ((m = LOCALE_RE.exec(ln)) !== null) {
const n = parseNumberToken(m[1]);
if (n === null) continue;
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'E',
claim: `${m[1]} ${m[2]}`
};
if (n !== CANONICAL_LOCALES) {
// Subset-reference suppressor: if the line mentions
// a known subset marker, treat the non-canonical
// number as a subset, not as drift. See comment on
// LOCALE_SUBSET_MARKERS for rationale.
const isSubsetRef = LOCALE_SUBSET_MARKERS.some((mark) =>
ln.toLowerCase().includes(mark.toLowerCase())
);
if (!isSubsetRef) {
sc.failure = `claims ${n} ${m[2]} but ${CANONICAL_LOCALES} locale JSONs ship`;
}
}
scenarios.push(sc);
}
// (F) ADR count
ADR_RE.lastIndex = 0;
while ((m = ADR_RE.exec(ln)) !== null) {
const n = parseNumberToken(m[1]);
if (n === null) continue;
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'F',
claim: `${m[1]} ${m[2]}`
};
if (n !== CANONICAL_ADRS) {
sc.failure = `claims ${n} but docs/adr/ has ${CANONICAL_ADRS} non-template ADRs`;
}
scenarios.push(sc);
}
// (G) footer count (brag list only)
if (docRel === 'MORPHIT-BRAG-LIST.md') {
FOOTER_RE.lastIndex = 0;
while ((m = FOOTER_RE.exec(ln)) !== null) {
const n = parseInt(m[1], 10);
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'G',
claim: `footer "${n} specific selling points"`
};
if (n !== CANONICAL_BRAG_ENTRIES) {
sc.failure = `footer claims ${n} but actual numbered entries = ${CANONICAL_BRAG_ENTRIES}`;
}
scenarios.push(sc);
}
}
// (H) ADR filename-range claim — cp131 MED-004.
ADR_RANGE_RE.lastIndex = 0;
while ((m = ADR_RANGE_RE.exec(ln)) !== null) {
const startN = parseInt(m[1]!, 10);
const endN = parseInt(m[2]!, 10);
const sc: Scenario = {
doc: docRel,
line: i + 1,
kind: 'H',
claim: `ADR range ${m[1]}-… through ${m[2]}-…`
};
if (endN !== CANONICAL_ADR_MAX) {
sc.failure =
`claims ADR range ends at ${m[2]} but highest ADR ` +
`on disk is ${String(CANONICAL_ADR_MAX).padStart(4, '0')}`;
} else if (startN !== 1) {
// Sanity: ADR ranges that don't start at 0001
// would be confusing — ADRs are an append-only
// log starting from 0001.
sc.failure = `claims ADR range starts at ${m[1]} but ADRs begin at 0001`;
}
scenarios.push(sc);
}
}
}
// Tally by kind
const byKind: Record<string, { pass: number; fail: number }> = {};
for (const s of scenarios) {
const k = s.kind;
if (!byKind[k]) byKind[k] = { pass: 0, fail: 0 };
if (s.failure) byKind[k].fail++;
else byKind[k].pass++;
}
const KIND_LABEL: Record<string, string> = {
A: 'file-path references',
B: 'custom-JSON op IDs',
C: 'operator env-vars',
D: 'tradable-asset count claims',
E: 'locale/language count claims',
F: 'ADR count claims',
G: 'brag-list footer count',
H: 'ADR filename-range claims'
};
for (const k of ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']) {
const b = byKind[k] ?? { pass: 0, fail: 0 };
const tot = b.pass + b.fail;
console.log(` ${k}. ${KIND_LABEL[k]}: ${tot} (${b.fail} failed)`);
}
const failures = scenarios.filter((s) => s.failure);
const total = scenarios.length;
// Floor: if the regexes broke and matched nothing, the smoke
// would pass-zero. We've manually checked the brag list has
// well over 50 verifiable claims today; pin a conservative
// floor that won't trip on legitimate copy-edits but will
// trip on a broken regex.
const MIN_SCENARIOS = 50;
if (total < MIN_SCENARIOS) {
console.log(
`\n ✗ only ${total} scenarios — below floor of ${MIN_SCENARIOS}; regex is likely broken`
);
console.log('\n──────────────────────────────────────────────────────');
console.log(`✗ 1/1 scenarios failed`);
process.exit(1);
}
if (failures.length > 0) {
console.log(`\n ✗ ${failures.length} claim(s) do not match canonical source:`);
for (const f of failures) {
console.log(` - [${f.kind}] ${f.doc}:${f.line} ${f.failure}`);
}
console.log('\n──────────────────────────────────────────────────────');
console.log(`${failures.length}/${total} scenarios failed`);
process.exit(1);
}
console.log(
`\n ✓ all ${total} brag-list claims align with canonical source`
);
console.log('\n──────────────────────────────────────────────────────');
console.log(`✓ all ${total} scenarios passed`);