2026-08-16 14:50:39 +02:00
|
|
|
// Renders .superpowers/findings-ledger.json to FINDINGS.md, and validates it.
|
|
|
|
|
// Run: node .superpowers/render-ledger.mjs # write FINDINGS.md
|
|
|
|
|
// node .superpowers/render-ledger.mjs --check # validate only
|
|
|
|
|
// node .superpowers/render-ledger.mjs --selftest # run built-in tests
|
2026-08-28 06:54:32 +02:00
|
|
|
import assert from "node:assert/strict";
|
2026-08-16 14:50:39 +02:00
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
const VALID_STATUS = ["open", "fixed", "declined", "refuted", "duplicate", "blocked"];
|
|
|
|
|
|
|
|
|
|
// Must stay in lockstep with SEV_RANK below. render() sorts the open section by
|
|
|
|
|
// SEV_RANK, and an unranked severity makes the comparator return NaN — which
|
|
|
|
|
// leaves the sort order implementation-defined, so the rendering would stop
|
|
|
|
|
// being a pure function of the ledger. The drift gate compares the rendering
|
|
|
|
|
// against the ledger, so its whole premise rests on this being enforced.
|
|
|
|
|
const VALID_SEVERITY = ["critical", "high", "medium", "low"];
|
2026-08-16 14:50:39 +02:00
|
|
|
|
|
|
|
|
export function validate(ledger) {
|
2026-08-28 06:54:32 +02:00
|
|
|
const problems = [];
|
|
|
|
|
const ids = new Set();
|
2026-08-16 14:50:39 +02:00
|
|
|
for (const r of ledger.findings) {
|
2026-08-28 06:54:32 +02:00
|
|
|
if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`);
|
|
|
|
|
ids.add(r.id);
|
|
|
|
|
if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`);
|
|
|
|
|
if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`);
|
|
|
|
|
if (!VALID_SEVERITY.includes(r.severity)) problems.push(`${r.id}: bad severity ${r.severity}`);
|
|
|
|
|
if (r.status === "fixed" && (!r.fix || !r.fix.commit))
|
|
|
|
|
problems.push(`${r.id}: fixed without a commit`);
|
|
|
|
|
if (r.status === "declined" && !r.rationale)
|
|
|
|
|
problems.push(`${r.id}: declined without a rationale`);
|
|
|
|
|
if (r.status === "duplicate" && !r.duplicateOf)
|
|
|
|
|
problems.push(`${r.id}: duplicate without duplicateOf`);
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
2026-08-28 06:54:32 +02:00
|
|
|
return problems;
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selftest() {
|
2026-08-28 06:54:32 +02:00
|
|
|
// Every fixture carries a severity: validate() now requires one, so omitting
|
|
|
|
|
// it would make each case report two problems and assert against the wrong one.
|
|
|
|
|
assert.deepEqual(validate({ findings: [] }), []);
|
2026-08-16 14:50:39 +02:00
|
|
|
assert.deepEqual(
|
2026-08-28 06:54:32 +02:00
|
|
|
validate({ findings: [{ id: "OC-0001", severity: "low", status: "fixed", fix: null }] }),
|
|
|
|
|
["OC-0001: fixed without a commit"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(validate({ findings: [{ id: "bad", severity: "low", status: "open" }] }), [
|
|
|
|
|
"bad: malformed id",
|
|
|
|
|
]);
|
2026-08-16 14:50:39 +02:00
|
|
|
assert.deepEqual(
|
2026-08-28 06:54:32 +02:00
|
|
|
validate({
|
|
|
|
|
findings: [
|
|
|
|
|
{ id: "OC-0001", severity: "low", status: "open" },
|
|
|
|
|
{ id: "OC-0001", severity: "low", status: "open" },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["duplicate id OC-0001"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
validate({ findings: [{ id: "OC-0002", severity: "low", status: "declined" }] }),
|
|
|
|
|
["OC-0002: declined without a rationale"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// An unranked severity is what makes render()'s sort implementation-defined.
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
validate({ findings: [{ id: "OC-0003", severity: "moderate", status: "open" }] }),
|
|
|
|
|
["OC-0003: bad severity moderate"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(validate({ findings: [{ id: "OC-0004", status: "open" }] }), [
|
|
|
|
|
"OC-0004: bad severity undefined",
|
|
|
|
|
]);
|
|
|
|
|
for (const sev of VALID_SEVERITY) {
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
validate({ findings: [{ id: "OC-0005", severity: sev, status: "open" }] }),
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
console.log("selftest: all assertions pass");
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
2026-08-16 14:50:39 +02:00
|
|
|
|
|
|
|
|
export function render(ledger) {
|
2026-08-28 06:54:32 +02:00
|
|
|
const by = (s) => ledger.findings.filter((f) => f.status === s);
|
|
|
|
|
const open = by("open").sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]);
|
|
|
|
|
const blocked = by("blocked");
|
|
|
|
|
const fixed = by("fixed");
|
|
|
|
|
const declined = by("declined");
|
|
|
|
|
const refuted = by("refuted");
|
|
|
|
|
const dup = by("duplicate");
|
2026-08-16 14:50:39 +02:00
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
const lines = [];
|
|
|
|
|
lines.push("# OwnCord Findings Ledger", "");
|
|
|
|
|
lines.push(
|
|
|
|
|
"Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.",
|
|
|
|
|
"",
|
|
|
|
|
);
|
2026-08-16 14:50:39 +02:00
|
|
|
lines.push(
|
|
|
|
|
`**${open.length} open** · ${blocked.length} blocked · ${fixed.length} fixed · ` +
|
|
|
|
|
`${declined.length} declined · ${refuted.length} refuted · ${dup.length} duplicate`,
|
2026-08-28 06:54:32 +02:00
|
|
|
"",
|
|
|
|
|
);
|
2026-08-16 14:50:39 +02:00
|
|
|
|
|
|
|
|
const section = (title, rows, extra) => {
|
2026-08-28 06:54:32 +02:00
|
|
|
if (!rows.length) return;
|
|
|
|
|
lines.push(`## ${title}`, "");
|
2026-08-16 14:50:39 +02:00
|
|
|
for (const r of rows) {
|
2026-08-28 06:54:32 +02:00
|
|
|
lines.push(`### ${r.id} — ${r.severity} — ${r.title}`, "");
|
|
|
|
|
lines.push(
|
|
|
|
|
`\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``,
|
|
|
|
|
"",
|
|
|
|
|
);
|
|
|
|
|
if (r.why) lines.push(r.why, "");
|
|
|
|
|
if (r.repro) lines.push(`**Repro:** ${r.repro}`, "");
|
|
|
|
|
if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, "");
|
|
|
|
|
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, "");
|
|
|
|
|
const e = extra && extra(r);
|
|
|
|
|
if (e) lines.push(e, "");
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
2026-08-28 06:54:32 +02:00
|
|
|
};
|
2026-08-16 14:50:39 +02:00
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
section("Open", open);
|
|
|
|
|
section("Blocked — fix attempted, revert-proof failed", blocked);
|
|
|
|
|
section(
|
|
|
|
|
"Fixed",
|
|
|
|
|
fixed,
|
|
|
|
|
(r) =>
|
|
|
|
|
`**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`,
|
|
|
|
|
);
|
|
|
|
|
section("Declined", declined, (r) => `**Declined:** ${r.rationale}`);
|
|
|
|
|
section("Refuted", refuted);
|
|
|
|
|
section("Duplicate", dup, (r) => `**Duplicate of** ${r.duplicateOf}`);
|
|
|
|
|
return lines.join("\n");
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main() {
|
2026-08-28 06:54:32 +02:00
|
|
|
const { readFileSync, writeFileSync } = await import("node:fs");
|
|
|
|
|
const { dirname, join } = await import("node:path");
|
|
|
|
|
const { fileURLToPath } = await import("node:url");
|
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
const ledger = JSON.parse(readFileSync(join(here, "findings-ledger.json"), "utf8"));
|
|
|
|
|
const problems = validate(ledger);
|
2026-08-16 14:50:39 +02:00
|
|
|
if (problems.length) {
|
2026-08-28 06:54:32 +02:00
|
|
|
for (const p of problems) console.error(`INVALID ${p}`);
|
|
|
|
|
process.exit(1);
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
2026-08-28 06:54:32 +02:00
|
|
|
if (process.argv.includes("--check")) {
|
|
|
|
|
console.log(`ledger valid: ${ledger.findings.length} finding(s)`);
|
|
|
|
|
return;
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
2026-08-28 06:54:32 +02:00
|
|
|
writeFileSync(join(here, "FINDINGS.md"), render(ledger) + "\n");
|
|
|
|
|
console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`);
|
2026-08-16 14:50:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
if (process.argv.includes("--selftest")) selftest();
|
|
|
|
|
else await main();
|