igneum/tools/ledger/export-public.mjs

154 lines
13 KiB
JavaScript

#!/usr/bin/env node
// The public shape of the criticism ledger (main's ruling, 7 October 2026, 19:5x UK): docs/ledger-public.md, one row per item
// (id, the claim or criticism in one line, status, what was done in one line, the evidence link), generated from
// docs/fud-ledger.md so it never drifts. Nothing from the ledger's round sections or status-update sections; no internal commit
// ids (7 to 40 hex), no lane or agent names, no time of day (dates stay at the day). The founder check reads the output like
// every tracked file.
//
// node tools/ledger/export-public.mjs # rewrite docs/ledger-public.md from docs/fud-ledger.md
// node tools/ledger/export-public.mjs --check # exit 1 when docs/ledger-public.md differs from what the ledger generates, or when an entry's
// # last Status paragraph starts with a date or a word outside the six statuses (the gate)
// node tools/ledger/export-public.mjs --self-test # a fixture ledger with a commit id, a time, a lane name and a Fix line gives rows
// # with none of them and the right fields; --check fails on a drifted output
// Node 22, standard library only.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..');
const LEDGER = path.join(ROOT, 'docs', 'fud-ledger.md');
const OUT = path.join(ROOT, 'docs', 'ledger-public.md');
const ITEM = /^### ([A-Z][A-Z0-9-]*?)\. (.+)$/;
export function parseItems(text) {
// an item runs from its heading to the next ### or ## heading; the round, status-update and count sections carry no items
// of their own shape except the headings they introduce, which are items like any other
const lines = text.split('\n'); const items = [];
let cur = null;
for (const line of lines) {
const m = line.match(ITEM);
if (m) { cur = { id: m[1], title: m[2].trim(), body: [] }; items.push(cur); continue; }
if (/^##? /.test(line) || /^### /.test(line)) { cur = null; continue; }
if (cur) cur.body.push(line);
}
return items;
}
const field = (body, name) => { // the LAST paragraph starting "<name>" (a later status line supersedes), joined to one line
let found = '';
for (let i = 0; i < body.length; i++) {
if (new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${name.endsWith(':') ? '' : '\\b'}`).test(body[i])) {
let j = i, para = [];
while (j < body.length && body[j].trim()) { para.push(body[j]); j++; }
found = para.join(' ');
}
}
return found;
};
const firstSentence = (s) => {
const t = s.replace(/\s+/g, ' ').trim();
const m = t.match(/^(.*?[.!?])(\s|$)(?![a-z0-9])/);
return (m ? m[1] : t).trim();
};
export function scrub(s) {
return s
.replace(/`?\b[0-9a-f]{7,40}\b`?/g, (x) => (/[a-f]/.test(x) && /[0-9]/.test(x) ? 'a commit' : x)) // a commit id has both letters and digits
.replace(/\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?(?:UTC|UK|BST|GMT|Z))?\b/g, '')
.replace(/\b\d{2}:[0-9x]{2}(?:\s?(?:UTC|UK|BST|GMT|Z))?\b/g, '')
.replace(/,\s*(?:night|evening|afternoon|morning|midday|noon|late|early)(?=[,)\s.;:])/gi, '')
.replace(/\b(?:the\s+)?[a-z0-9-]+\s+lane(?:'s)?\b/gi, 'the team')
.replace(/\bmain's\b/g, "the team's").replace(/\bby main\b/g, 'by the team')
.replace(/\b(?:the\s+)?(?:ledger closer|shipper|reviewer|coordinator|orchestrator|steward)(?:'s)?\b/gi, 'the team')
.replace(/\b[Bb]ranch\s+`[^`]+`/g, 'a branch').replace(/\b[Ff]ork\s+`[^`]+`/g, 'a fork').replace(/\bbranches?\s+[a-z0-9][a-z0-9.-]*(?:\s+and\s+[a-z0-9][a-z0-9.-]*)?\b/g, 'a branch')
.replace(/,\s*;/g, ';').replace(/\(\s*[;,]\s*/g, '(').replace(/\(\s*\)/g, '').replace(/,\s*\)/g, ')').replace(/\s+([,.;:)])/g, '$1').replace(/\s{2,}/g, ' ').trim();
}
const cell = (s, max) => { s = s.replace(/\|/g, '\\|'); return s.length > max ? s.slice(0, max - 1).replace(/\s+\S*$/, '') + '…' : s; };
// The status-word rule (main, 7 October 2026, night, from the site audit's finding that an appended update can miss the public
// row): an entry's current status is the FIRST SENTENCE of its last Status paragraph, the previous one follows "Was:", and the
// paragraph must START with one of the six statuses' words (Open; Conceded; Fixed, Rolled out, Rule fixed, Spec fixed, Rule
// implemented, Rule written, Written, Designed; Closed, Decided; Answered with evidence, Measured, Simulation half; Answered by
// design), never with a date or a word outside them (Relabelled, Reopened, Corrected, Kept were the four the /ledger page could
// not place on 7 October 2026). --check fails on any such entry and names it; the self-test plants both shapes first.
export const STATUS_WORD = /^(open|conceded|fixed|rolled out|rule fixed|spec fixed|rule implemented|rule written|written|designed|closed|decided|answered with evidence|measured|simulation half|answered by design)\b/i;
export function badStatuses(text) {
return parseItems(text).map((it) => [it.id, field(it.body, 'Status:').replace(/^Status:\s*/, '')]).filter(([, st]) => !STATUS_WORD.test(st)).map(([id, st]) => `${id}: "${st.slice(0, 60)}"`);
}
export function rowOf(item) {
const status = field(item.body, 'Status:').replace(/^Status:\s*/, '');
let statusShort = status.split(/[(:]/)[0].replace(/[.,;\s]+$/, '').trim() || 'unstated';
const fix = field(item.body, 'Fix'); const answer = field(item.body, 'Answer:'); const evidence = field(item.body, 'Evidence:');
// what was done, in order of worth: the Fix line; the status line's own remainder ("Decided (...): no standing bounty."); the Answer
let done = '';
const statusRest = status.replace(/^[^:(]*(?:\([^)]*\))?\s*:?\s*/, '');
if (fix) done = firstSentence(fix.replace(/^Fix(?:\s+needed)?\s*(?:\([^)]*\))?\s*[:,]?\s*/, ''));
else if (statusRest && statusRest !== status) done = firstSentence(statusRest);
else if (answer) done = firstSentence(answer.replace(/^Answer:\s*/, ''));
if (!/[A-Za-z0-9]/.test(done) && answer) done = firstSentence(answer.replace(/^Answer:\s*/, '')); // a status with no remainder
done = done.replace(/^(["'`]?)([a-z])/, (m, q, c) => q + c.toUpperCase());
// the evidence link: the first repository path in backticks, from Evidence:, then the fix, then the answer (a file, or a directory with its slash)
let link = '';
for (const src of [evidence, fix, answer]) {
const m = src && src.match(/`((?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]+)?)`/);
if (m) { link = `[${m[1]}](../${m[1]})`; break; }
}
// no repository path: the evidence names a document section or a measurement in words, so the words stand (scrubbed, one line)
if (!link) { const words = firstSentence(evidence.replace(/^Evidence:\s*/, '').replace(/\s*Fix:.*$/, '')); link = words && /[A-Za-z]/.test(words) ? cell(scrub(words), 120) : 'none named'; }
return { id: item.id, claim: cell(scrub(item.title), 140), status: cell(scrub(statusShort), 60), done: cell(scrub(done), 220), evidence: link };
}
export function render(text) {
const items = parseItems(text); const rows = items.map(rowOf);
const byStatus = {}; for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
const out = [];
out.push('# Igneum criticism ledger, public shape');
out.push('');
out.push('Generated by `tools/ledger/export-public.mjs` from `docs/fud-ledger.md`; a gate check fails when the two drift. One row per item: the claim or criticism, its status, what was done, and the evidence. Internal identifiers, times of day and team-member names are left out on purpose; the full ledger is published with the repository.');
out.push('');
out.push(`${rows.length} items. By status: ${Object.entries(byStatus).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join('; ')}.`);
out.push('');
out.push('| Id | Claim or criticism | Status | What was done | Evidence |');
out.push('|---|---|---|---|---|');
for (const r of rows) out.push(`| ${r.id} | ${r.claim} | ${r.status} | ${r.done} | ${r.evidence} |`);
out.push('');
return out.join('\n');
}
function selfTest() {
const fx = `# ledger\n\n## 1. Section\n\n### M1. The program space is tiny\n"Eleven ops." Said on 3 October 2026.\n\nStatus: Decided (6 October 2026, 17:35 UTC, by the owner): no bounty. Second sentence.\n\nAnswer: Correct that the arithmetic is simple. More.\n\nEvidence: \`docs/bench-log.md\` (RTX 5090 sweep), commit 0e2d6b1c.\n\n### F1. Finality is attackable for the first month\nText.\n\nStatus: Fixed on a branch, pending merge (5 October 2026, night): fork branch \`ledger-fixes-0311\` fbb0082a.\n\nFix (5 October 2026, night), the harness text only: \`tools/finality-attacks/run.mjs\` names the floor (the hash lane, 14:44 UTC; main's ruling). Branch \`fin-fixes\` da1eb889 merged.\n\nEvidence: see the fix.\n\n## Round 3 entries\n\n### AP-F8-1. A load whose source was last written by \`or\` makes a hot set\nStatus: Fixed (7 October 2026, 13:31:10Z, the attack-pass lane).\n\n## Count by status\n\n| x | y |\n`;
const fails = [];
const items = parseItems(fx);
if (items.map((i) => i.id).join(',') !== 'M1,F1,AP-F8-1') fails.push(`items: ${items.map((i) => i.id).join(',')}`);
const rows = items.map(rowOf);
const text = render(fx);
if (rows[0].status !== 'Decided' || rows[0].done !== 'No bounty.' || rows[0].evidence !== '[docs/bench-log.md](../docs/bench-log.md)') fails.push(`M1 row: ${JSON.stringify(rows[0])}`);
if (rows[1].status !== 'Fixed on a branch, pending merge' || !/^The harness text only: `tools\/finality-attacks\/run\.mjs` names the floor/.test(rows[1].done)) fails.push(`F1 row: ${JSON.stringify(rows[1])}`);
if (rows[1].evidence !== '[tools/finality-attacks/run.mjs](../tools/finality-attacks/run.mjs)') fails.push(`F1 evidence: ${rows[1].evidence}`);
if (rows[2].evidence !== 'none named') fails.push(`AP-F8-1 evidence without an Evidence line: ${rows[2].evidence}`);
for (const bad of [/0e2d6b1c/, /fbb0082a/, /da1eb889/, /\d\d:\d\d/, /hash lane/, /attack-pass lane/, /main's/, /, night/, /fin-fixes/, /ledger-fixes-0311/]) if (bad.test(text)) fails.push(`scrub: ${bad} survived: ${text.match(bad)?.input?.slice(0, 0)}${(text.split('\n').find((l) => bad.test(l)) || '').slice(0, 160)}`);
if (!/\| AP-F8-1 \| A load whose source was last written by `or` makes a hot set \| Fixed \|/.test(text)) fails.push('AP-F8-1 row missing or wrong');
if (!/^3 items\. By status: /m.test(text)) fails.push('count line');
// the status-word rule, known-failed first: a date-led and a word-led status both fail; the fixture's three pass
const bad = `# ledger\n\n### Z1. A date-led status\n"x"\n\nStatus: 7 October 2026, morning: relabelled.\n\n### Z2. A word outside the six\n"y"\n\nStatus: Relabelled (7 October 2026): the range stands.\n\n### Z3. A fine one\n"z"\n\nStatus: Conceded, stated (7 October 2026). Was: Conceded.\n`;
const b = badStatuses(bad);
if (b.length !== 2 || !b[0].startsWith('Z1:') || !b[1].startsWith('Z2:')) fails.push(`status-word rule: known-failed shapes not caught: ${JSON.stringify(b)}`);
if (badStatuses(fx).length) fails.push(`status-word rule: the fixture's statuses were refused: ${JSON.stringify(badStatuses(fx))}`);
// --check: a drifted output fails, the generated one passes
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-public-')); const o = path.join(dir, 'out.md');
fs.writeFileSync(o, render(fx)); if (!check(fx, o)) fails.push('check: a fresh output was reported as drifted');
fs.appendFileSync(o, 'drift\n'); if (check(fx, o)) fails.push('check: a drifted output passed');
fs.rmSync(dir, { recursive: true, force: true });
if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); }
console.log('self-test passed: one row per item with id, claim, status, what was done and the evidence link; commit ids, times of day, lane and agent names and branch names are gone; --check fails on drift and passes on the generated file');
}
export function check(text, outFile) { return fs.existsSync(outFile) && fs.readFileSync(outFile, 'utf8') === render(text); }
const arg = process.argv[2];
if (arg === '--self-test') selfTest();
else if (arg === '--check') {
const bad = badStatuses(fs.readFileSync(LEDGER, 'utf8'));
if (bad.length) { console.error(`ledger-public: ${bad.length} entr${bad.length === 1 ? 'y' : 'ies'} whose last Status paragraph does not start with one of the six statuses' words (the current status is its first sentence; the previous follows "Was:"): ${bad.join('; ')}`); process.exit(1); }
const ok = check(fs.readFileSync(LEDGER, 'utf8'), OUT);
console.log(ok ? `ledger-public: docs/ledger-public.md matches docs/fud-ledger.md (${parseItems(fs.readFileSync(LEDGER, 'utf8')).length} items)` : 'ledger-public: docs/ledger-public.md differs from what docs/fud-ledger.md generates; run node tools/ledger/export-public.mjs and commit');
process.exit(ok ? 0 : 1);
} else {
const text = fs.readFileSync(LEDGER, 'utf8'); fs.writeFileSync(OUT, render(text));
console.log(`ledger-public: wrote docs/ledger-public.md, ${parseItems(text).length} items`);
}