import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { RULES } from "../src/rules.js"; import { ratify, ratifyFile, parseManifest, defaultContractPath, } from "../src/ratify.js"; const kinds = (r: { issues: { kind: string }[] }) => r.issues.map((i) => i.kind); /** A minimal contract carrying the required section headings + a manifest fence. */ function contractWith(manifestRows: string[], opts: { bRows?: number } = {}): string { const bRows = opts.bRows ?? 10; const bTable = Array.from({ length: bRows }, (_, i) => `| ${i + 1} | regel ${i + 1} | hvorfor | flagg |`).join("\n"); return [ "## B. De ti reglene", "| # | Regel | Hvorfor | Flagg |", "|---|---|---|---|", bTable, "", "## C1. Regelsjekk-gate", "## C2. Redaktør-pass", "## E. Akkumulering", "", "| rule-id | ref | severity | proveniens |", "|---------|-----|----------|------------|", ...manifestRows, "", ].join("\n"); } /** One manifest row per rule, transcribing id/ref/severity exactly. */ const validRows = RULES.map((r) => `| ${r.id} | ${r.ref} | ${r.severity} | prov |`); describe("ratify — rules.ts ↔ §E-manifest binding", () => { test("a manifest that mirrors RULES exactly is in sync", () => { const r = ratify(contractWith(validRows)); assert.equal(r.ok, true, `unexpected issues: ${JSON.stringify(r.issues)}`); assert.equal(r.issues.length, 0); assert.equal(r.ruleCount, RULES.length); assert.equal(r.manifestCount, RULES.length); }); test("a missing manifest fence is a hard failure", () => { const r = ratify("## B. regler\nno manifest here at all\n"); assert.equal(r.ok, false); assert.ok(kinds(r).includes("missing-from-manifest")); assert.equal(r.manifestCount, 0); }); test("a rule with no manifest row → missing-from-manifest", () => { // drop the first rule's row const rows = validRows.slice(1); const r = ratify(contractWith(rows)); assert.equal(r.ok, false); const missing = r.issues.filter((i) => i.kind === "missing-from-manifest"); assert.equal(missing.length, 1); assert.equal(missing[0].id, RULES[0].id); }); test("a manifest row with no rule → extra-in-manifest", () => { const rows = [...validRows, "| ghost-rule | §B-2 | warn | spøkelse |"]; const r = ratify(contractWith(rows)); assert.equal(r.ok, false); const extra = r.issues.filter((i) => i.kind === "extra-in-manifest"); assert.equal(extra.length, 1); assert.equal(extra[0].id, "ghost-rule"); }); test("a ref that differs from rules.ts → ref-mismatch", () => { const rows = validRows.map((row, i) => i === 0 ? `| ${RULES[0].id} | §B-99 | ${RULES[0].severity} | x |` : row, ); const r = ratify(contractWith(rows)); assert.equal(r.ok, false); assert.ok(r.issues.some((i) => i.kind === "ref-mismatch" && i.id === RULES[0].id)); }); test("a severity that differs from rules.ts → severity-mismatch", () => { const flip = RULES[0].severity === "block" ? "warn" : "block"; const rows = validRows.map((row, i) => i === 0 ? `| ${RULES[0].id} | ${RULES[0].ref} | ${flip} | x |` : row, ); const r = ratify(contractWith(rows)); assert.equal(r.ok, false); assert.ok(r.issues.some((i) => i.kind === "severity-mismatch" && i.id === RULES[0].id)); }); test("a §B-N ref pointing past the table → dangling-ref", () => { // §B only has 5 rows here, but RULES cites §B-10 (B10-serie-tese) const r = ratify(contractWith(validRows, { bRows: 5 })); assert.equal(r.ok, false); assert.ok(r.issues.some((i) => i.kind === "dangling-ref")); }); test("a duplicate manifest id is flagged", () => { const rows = [...validRows, validRows[0]]; const r = ratify(contractWith(rows)); assert.equal(r.ok, false); assert.ok(r.issues.some((i) => i.kind === "extra-in-manifest" && i.detail.includes("duplisert"))); }); }); describe("parseManifest", () => { test("returns null when the fence is absent", () => { assert.equal(parseManifest("no markers"), null); }); test("skips header + separator, keeps data rows", () => { const rows = parseManifest(contractWith(validRows)); assert.ok(rows); assert.equal(rows!.length, RULES.length); assert.equal(rows![0].id, RULES[0].id); assert.equal(rows![0].severity, RULES[0].severity); }); }); describe("ratifyFile — real contract integration", () => { const real = defaultContractPath(); // Skips on a fresh clone / CI where maskinrommet is absent; runs on KTG's machine. test("the live skrivekontrakt.md §E-manifest ratifies clean", { skip: !existsSync(real) }, () => { const r = ratifyFile(real); assert.equal(r.ok, true, `drift vs live contract: ${JSON.stringify(r.issues, null, 2)}`); assert.equal(r.ruleCount, RULES.length); }); test("a non-existent contract path fails cleanly (no throw)", () => { const r = ratifyFile("/nonexistent/skrivekontrakt.md"); assert.equal(r.ok, false); assert.equal(r.contractPath, "/nonexistent/skrivekontrakt.md"); }); });