#!/usr/bin/env node // Renders docs/fud-ledger.md as the public page site/ledger.html: every entry, the critic's words, what was done, the // status and the date, with the count table on top. Nothing is dropped and nothing is softened; the only rewrites are // the identity and provider terms of tools/ci/forbidden-strings.txt and the leak classes below (config and home paths, // process ids, listen addresses, machine names, repository file paths), applied in place; the rendered page is then // grepped against the same list and the render fails on a hit. // Usage: node tools/ledger-page.mjs [docs/fud-ledger.md] [site/ledger.html] import { readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); const src = process.argv[2] || join(root, 'docs', 'fud-ledger.md'); const out = process.argv[3] || join(root, 'site', 'ledger.html'); const esc = s => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); // Identity and provider terms. The criticism keeps its force; the operational name goes. const SCRUB = [ [/\bJosh's\b/g, "the owner's"], [/\bJosh\b/g, 'the owner'], [/\bHetzner\b/g, 'the cloud provider'], [/\bGoDaddy\b/g, 'the US registrar'], [/\bVercel\b/g, 'the host'], [/CLAUDE\.md/g, 'the project rules file'], [/\.claude\/agents\/?/g, 'the agent files '], [/\/opt\/igneum[^\s`,;)]*/g, 'the prover host directory'], [/dl\.igneum\.network/g, 'the downloads host'], [/log[-_]intake[-_]?key|LOG_INTAKE_KEY|intake[_-]?key/gi, 'the log key'], [/log-intake|LOG_INTAKE/g, 'the log intake'], [/\bintake id\b/g, 'the upload id'], [/\+0100/g, 'a local-time offset'], [/\bBST\b/g, 'local time'], [/\bTailscale\b|\bts\.net\b/g, 'the private network'], [/DESKTOP-[A-Z0-9]{7}/g, 'the PC'], [/MacBook/g, 'the laptop'], [/\bhcloud\b/g, 'the cloud CLI'], [/igneum-seed[0-9]*/g, 'the seed node'], [/\/root\//g, '//'], [/\/Users\/[^\s/`]+/g, '~'], [/C:\\Users\\[^\s\\`]+/g, '%USERPROFILE%'], [/~\/Desktop/g, '~/'], [/192\.168\.\d+\.\d+/g, ''], [/100\.\d+\.\d+\.\d+/g, ''], // 6 October 2026 (the public-page leak found by the site audit): config and home paths, process ids, listen addresses, // machine names and repository file paths never reach the page; the ledger id beside each entry is the reference [/~\/\.config\/[^\s`,;)]*/g, 'a config file'], [/~\/[A-Za-z0-9_.-]+(?:\/[^\s`,;)]*)?/g, 'a home-directory file'], [/\bpid \d+\b/g, 'the process'], [/--rpclisten=\S+/g, 'the RPC listen flag'], [/\b0\.0\.0\.0:\d+\b/g, 'the listen address'], [/\bPC [12]\b('s)?/g, (m, p) => 'the Windows machine' + (p || '')], [/\bthe Mac's\b/g, "the Apple M5 Max's"], [/\bthe Mac\b/g, 'the Apple M5 Max'], [/(? SCRUB.reduce((t, [re, r]) => t.replace(re, r), s); // Inline markdown: code spans and links only; the ledger uses nothing else inside a status line. function inline(s) { let t = esc(s); t = t.replace(/`([^`]+)`/g, (m, c) => `${c}`); t = t.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, (m, a, u) => `${a}`); return t; } const lines = readFileSync(src, 'utf8').split('\n'); const entries = []; let cur = null; for (const l of lines) { const m = /^### ([A-Z]\d+)\. (.*)$/.exec(l); if (m) { cur = { id: m[1], title: m[2].trim(), quote: '', status: '', answer: '' }; entries.push(cur); continue; } if (!cur) continue; const s = l.trim(); if (!cur.quote && s.startsWith('"')) cur.quote = s.replace(/^"|"$/g, ''); else if (!cur.status && s.startsWith('Status:')) cur.status = s.slice(7).trim(); else if (!cur.answer && s.startsWith('Answer:')) cur.answer = s.slice(7).trim(); } const SECTION = { M: 'Mining and chips', F: 'Finality and attacks', P: 'Proving and the zkEVM', E: 'Economics and the coin', G: 'Governance and the founders', C: 'Comparisons', L: 'Legal and regulatory', X: 'Launch and\u00a0operations', D: 'Builders' }; function bucket(status) { const s = status.toLowerCase(); if (/^(fixed|rolled out|rule fixed|spec fixed|rule implemented|rule written|written|designed)/.test(s)) return 'Fixed or built'; if (s.startsWith('conceded')) return 'Conceded'; if (s.startsWith('answered by design')) return 'Answered by design'; if (/^(answered with evidence|measured|simulation half)/.test(s)) return 'Answered with evidence'; if (/^(closed|decided)/.test(s)) return 'Closed by rule or decided'; if (s.startsWith('open')) return 'Open'; return 'Other'; } function statusWord(status) { const m = /^([^(:.]+?)(?=\s*[(:.]|$)/.exec(status); return (m ? m[1] : status).trim(); } function dateOf(status) { const m = /(\d{1,2} October 2026)/.exec(status); return m ? m[1] : '3 October 2026'; } const ORDER = ['Open', 'Conceded', 'Fixed or built', 'Closed by rule or decided', 'Answered with evidence', 'Answered by design', 'Other']; const MEANING = { 'Open': 'Nothing has settled it yet. The entry names what will', 'Conceded': 'The critic is right. "Stated" means the public text says so; "not yet stated" means it does not yet', 'Fixed or built': 'A code, spec or text change answers it, with the commit or the page named', 'Closed by rule or decided': 'A consensus rule or a decision by the owner answers it, dated', 'Answered with evidence': 'A measurement or a simulation exists and is named', 'Answered by design': 'A design rule answers it; no measurement is possible yet', 'Other': 'A status outside the six above, read the line', }; const counts = {}; for (const e of entries) { const b = bucket(e.status); counts[b] = (counts[b] || 0) + 1; } const partial = name => readFileSync(join(root, 'site', 'partials', name), 'utf8').trim(); const HEAD = partial('head.html'), NAV = partial('nav.html'), FOOT = partial('footer.html'); // the ledger is a header item: the page underlines it like build.mjs's navFor does for the other pages const NAV_LEDGER = NAV.replace(/(hello@igneum.network with its id.`; const countRows = ORDER.filter(b => counts[b]).map(b => `${counts[b]}${esc(MEANING[b])}`).join('\n'); let body = ''; let lastSec = ''; for (const e of entries) { const sec = SECTION[e.id[0]] || e.id[0]; if (sec !== lastSec) { body += `

${esc(sec)}

\n`; lastSec = sec; } const b = bucket(e.status); const word = statusWord(scrub(e.status)); const rest = scrub(e.status).slice(word.length).replace(/^[\s(:.]+/, '').trim(); body += `
${e.id}

${inline(scrub(e.title))}

${esc(dateOf(e.status))}
${inline(scrub(e.quote))}
${esc(word)}${rest ? ` ${inline(rest)}` : ''}
${e.answer ? `
The answer as first written

${inline(scrub(e.answer))}

` : ''}
\n`; } const html = ` Igneum ledger: every criticism, answered ${HEAD} ${NAV_LEDGER}
Ledger · ${entries.length} entries · regenerated from the repository

Every criticism, answered
or conceded.

${intro}

${countRows}
CountStatusMeaning
${entries.length}Every entry. The sections: ${Object.entries(SECTION).map(([k, v]) => `${esc(v)}`).join(', ')}
${body}

Source: the project's criticism ledger, a file in the repository, rendered to this page at build time; the repository is published at the public testnet. A criticism that is not here, or that shows an entry is wrong, is added with credit if wanted: hello@igneum.network or an issue on the specification repository.

${FOOT} `; // hard stop (6 October 2026): the rendered page is grepped with the committed forbidden list plus the leak classes above const forbid = readFileSync(join(root, 'tools', 'ci', 'forbidden-strings.txt'), 'utf8').split('\n').filter(l => l.trim() && !l.startsWith('#')) .concat(['~/\\.config', '\\bpid \\d+\\b', '0\\.0\\.0\\.0:\\d+', '\\bPC [12]\\b', '\\bthe Mac\\b', '/Users/']); const leaks = []; for (const pat of forbid) { let re; try { re = new RegExp(pat, 'm'); } catch { continue; } const m = re.exec(html); if (m) leaks.push(`${pat}: ...${html.slice(Math.max(0, m.index - 40), m.index + 60).replace(/\s+/g, ' ')}...`); } if (leaks.length) { console.error('ledger page: forbidden text would reach the public page:'); leaks.forEach(l => console.error(' ' + l)); process.exit(1); } writeFileSync(out, html); console.log(`${out}: ${entries.length} entries, counts ${JSON.stringify(counts)}`);