morphit/scripts/release-notes-asset-count-parity-smoke.ts

243 lines
9.3 KiB
TypeScript

#!/usr/bin/env tsx
/**
* scripts/release-notes-asset-count-parity-smoke.ts
*
* Structural Defense #36 — release-notes asset-count parity
* (cp84 Lesson #4 #1, shipped cp85).
*
* Catches the cp84-A1 drift class: RELEASE-NOTES-*.md carries
* literal asset-count claims ("Sixteen tradable assets",
* "thirteen are trade-only", "Three can pay listing fees") that
* silently go stale as new assets are added to the registry.
*
* The cp84 manual fix touched 5 sections to bring counts back
* in line. Without this smoke, the next asset addition would
* leak the same drift.
*
* Source of truth:
*
* - Total tradable count: `ASSET_TICKERS.length` in
* `packages/asset-registry/src/index.ts`.
* - Fee-eligible count: count of `canPayListingFee: true` in
* `packages/asset-registry/src/index.ts`.
* - Trade-only count: total - fee-eligible.
*
* Pattern set (English release notes only — translated FAQ
* entries are out of scope; they'd need per-locale count-word
* maps, deferred to a future smoke if drift surfaces in
* translations).
*
* 1. "<N> tradable assets" → matches ASSET_TICKERS.length
* 2. "<N> ... trade-only" → matches trade-only count
* 3. "<N> ... (can pay )?listing fees" → matches fee-eligible count
*
* Each pattern is matched in the RELEASE-NOTES-*.md file(s)
* found under repo root. Each match's count-word is compared
* to the truth-source.
*
* Each (file, pattern, match) tuple = one scenario.
*/
import { readFileSync, readdirSync } 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, '..');
console.log('\n── release-notes asset-count parity smoke ──────────────\n');
// ─── 1. Resolve truth-source counts from the registry ─────────────
const REGISTRY_PATH = join(REPO, 'packages/asset-registry/src/index.ts');
const registryText = readFileSync(REGISTRY_PATH, 'utf8');
// ASSET_TICKERS array literal — parse the bracketed list.
// Regex matches the export line; the count is the number of
// quoted strings inside.
const tickersMatch = registryText.match(
/export\s+const\s+ASSET_TICKERS\s*=\s*\[([^\]]+)\]/
);
if (!tickersMatch) {
console.log(' ✗ could not locate ASSET_TICKERS in registry');
console.log('\n✗ 1/1 scenarios failed');
process.exit(1);
}
const tickerStrings = tickersMatch[1]!.match(/'[A-Z0-9]+'/g) ?? [];
// cp425 — these release-notes counts describe the tradable *cryptocurrencies*
// ("N tradable assets", "N trade-only", "N can pay listing fees"). Goods assets
// (barter) are a distinct category with their own release-notes section, not one
// of the coins, so they must NOT count here. Derive the goods tickers straight
// from the isGoodsAsset predicate body and exclude them.
const goodsMatch = registryText.match(
/export\s+function\s+isGoodsAsset[^{]*\{([^}]+)\}/
);
const goodsTickers = new Set(goodsMatch ? (goodsMatch[1]!.match(/'[A-Z0-9]+'/g) ?? []) : []);
const cryptoTickers = tickerStrings.filter((t) => !goodsTickers.has(t));
const tradableCount = cryptoTickers.length;
// canPayListingFee: true count.
const feeEligibleMatches = registryText.match(/canPayListingFee:\s*true/g) ?? [];
const feeEligibleCount = feeEligibleMatches.length;
const tradeOnlyCount = tradableCount - feeEligibleCount;
console.log(` truth-source counts (from packages/asset-registry/src/index.ts):`);
console.log(` tradable = ${tradableCount}`);
console.log(` fee-eligible (canPayListingFee=true) = ${feeEligibleCount}`);
console.log(` trade-only = ${tradeOnlyCount}`);
// ─── 2. Count-word ↔ number map ──────────────────────────────────
const WORDS_TO_NUMBERS: 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
};
// Reverse map (1 → "one") so error messages can suggest the
// correct word.
const NUMBERS_TO_WORDS: Record<number, string> = {};
for (const [w, n] of Object.entries(WORDS_TO_NUMBERS)) {
NUMBERS_TO_WORDS[n] = w;
}
function parseCount(token: string): number | null {
const lower = token.toLowerCase();
if (lower in WORDS_TO_NUMBERS) return WORDS_TO_NUMBERS[lower]!;
const n = parseInt(lower, 10);
return isFinite(n) && n > 0 ? n : null;
}
// ─── 3. Find release-notes files ─────────────────────────────────
const allFiles = readdirSync(REPO);
const RELEASE_NOTES_FILES = allFiles
.filter((f) => /^RELEASE-NOTES-v[\w.-]+\.md$/.test(f))
.map((f) => join(REPO, f));
if (RELEASE_NOTES_FILES.length === 0) {
console.log(' ✗ no RELEASE-NOTES-v*.md files found at repo root');
console.log('\n✗ 1/1 scenarios failed');
process.exit(1);
}
console.log(` release-notes files: ${RELEASE_NOTES_FILES.length}`);
// ─── 4. Patterns + truth-source mapping ─────────────────────────
interface Pattern {
readonly name: string;
readonly regex: RegExp;
readonly expected: number;
}
const PATTERNS: Pattern[] = [
{
name: 'tradable-assets',
// "<N> tradable assets" — N is a count word or digit
regex: /\b([A-Za-z]+|\d+)\s+tradable\s+assets?\b/gi,
expected: tradableCount
},
{
name: 'trade-only',
// "<N> ... trade-only" — N is at the start of the trade-only
// claim sentence. Match the count word that comes
// immediately before "are trade-only" / "trade-only" so we
// don't false-fire on the asset-list flag inside e.g.
// "(trade-only on Morphit)". Looking for a pattern like:
// "the other thirteen are trade-only"
// "thirteen trade-only assets"
regex: /\b([A-Za-z]+|\d+)\s+(?:are\s+)?trade[-\s]only\b/gi,
expected: tradeOnlyCount
},
{
name: 'fee-eligible',
// "<N> — (BTC, XMR, BLURT) — ... listing fees" — the count
// word that precedes a "listing fee(s)" claim, allowing
// the claim to span line breaks in markdown.
// To avoid matching "listing fee is roughly $0.12" type
// statements which don't include a count word in lead,
// require count + dash/comma/word + listing fee within
// a short window. Period (`.`) is the only stop char —
// we WANT to cross newlines because asset list claims in
// release notes wrap.
regex:
/\b([A-Za-z]+|\d+)\s*(?:—|--|,|of\s+them|of\s+the\s+sixteen)[^.]{0,160}?listing\s+fees?\b/gi,
expected: feeEligibleCount
}
];
// ─── 5. Scan + verify ────────────────────────────────────────────
interface Finding {
file: string;
pattern: string;
expected: number;
actual: number;
matchText: string;
matchToken: string;
}
const findings: Finding[] = [];
let scenarios = 0;
for (const filePath of RELEASE_NOTES_FILES) {
const text = readFileSync(filePath, 'utf8');
const rel = filePath.replace(REPO + '/', '');
for (const pat of PATTERNS) {
pat.regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pat.regex.exec(text)) !== null) {
const token = m[1]!;
const actual = parseCount(token);
if (actual === null) {
// Token isn't a number/word — likely a false positive
// (e.g. "the trade-only assets" matched the trade-only
// pattern with token="the"). Skip silently.
continue;
}
scenarios++;
if (actual !== pat.expected) {
findings.push({
file: rel,
pattern: pat.name,
expected: pat.expected,
actual,
matchText: m[0].slice(0, 80),
matchToken: token
});
}
}
}
}
console.log(` pattern matches checked: ${scenarios}`);
if (scenarios === 0) {
console.log('\n ✗ no asset-count claims found in any release-notes file —');
console.log(' pattern set is broken, or release notes don\'t carry claims');
console.log(' (this is itself a regression — release notes should claim asset counts)');
console.log('\n✗ 1/1 scenarios failed');
process.exit(1);
}
if (findings.length > 0) {
console.log(`\n ✗ ${findings.length} mismatched asset-count claim(s):`);
for (const f of findings) {
const suggested = NUMBERS_TO_WORDS[f.expected] ?? String(f.expected);
console.log(
` - ${f.file} [${f.pattern}]: claims \`${f.matchToken}\` ` +
`but registry says ${f.expected} (\`${suggested}\`)`
);
console.log(` context: "${f.matchText.replace(/\s+/g, ' ').trim()}"`);
}
console.log(
`\n Fix: edit the release-notes file(s) so each count matches the` +
`\n registry, OR update the registry if the asset list changed and` +
`\n the release notes are the leading source.`
);
console.log('\n──────────────────────────────────────────────────────');
console.log(`${findings.length}/${scenarios} scenarios failed`);
process.exit(1);
}
console.log(` ✓ all ${scenarios} asset-count claims match the registry`);
console.log('\n──────────────────────────────────────────────────────');
console.log(`✓ all ${scenarios} scenarios passed`);