/** * Ratify — the binding half of accumulation (slice 2 of fix #1). * * Slice 1 binds *drafts* to the rules. Ratify binds the *rules* (`rules.ts`) to * the human source of truth (`maskinrommet/docs/skrivekontrakt.md`). Without it, * accumulation rots silently: add a rule to `rules.ts` and forget the contract * row (the gate enforces something undocumented), or codify a contract rule and * forget the gate entry (a "rule" nobody enforces). Either way the two drift. * * The contract carries a machine-readable "gate-bindings-manifest" — one row per * mechanical rule (id · ref · severity · provenance) — fenced between * `` and ``. Ratify asserts a BIJECTION * between that manifest and `RULES`, matching ref + severity exactly, and that * every cited §-anchor resolves to a real section. It imports RULES directly * (not a text-parse of rules.ts) so the gate's own loader is the source. * * Pure + deterministic: no AI, no network. Exit 0 = in sync, 1 = drift. */ import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import type { Rule, Severity } from "./types.js"; import { RULES } from "./rules.js"; export interface ManifestRow { id: string; ref: string; severity: string; provenance: string; } export type RatifyIssueKind = | "missing-from-manifest" // rule in RULES, no manifest row | "extra-in-manifest" // manifest row, no rule in RULES | "ref-mismatch" // shared id, ref differs | "severity-mismatch" // shared id, severity differs | "dangling-ref"; // manifest/rule ref cites a §-anchor the contract lacks export interface RatifyIssue { kind: RatifyIssueKind; id: string; detail: string; } export interface RatifyResult { contractPath: string | null; ruleCount: number; manifestCount: number; issues: RatifyIssue[]; ok: boolean; } /** Default contract path, resolved relative to THIS module (cwd-independent). */ export function defaultContractPath(): string { const here = dirname(fileURLToPath(import.meta.url)); // src → contract-gate → scripts → linkedin-studio → ktg-plugin-marketplace → repos return resolve(here, "../../../../../maskinrommet/docs/skrivekontrakt.md"); } /** * Extract the manifest rows fenced by ``. * Returns null if the fence is missing (a hard error — the contract must carry * the manifest for the binding to exist at all). */ export function parseManifest(contractText: string): ManifestRow[] | null { const begin = contractText.indexOf(""); const end = contractText.indexOf(""); if (begin === -1 || end === -1 || end < begin) return null; const block = contractText.slice(begin, end); const rows: ManifestRow[] = []; for (const raw of block.split("\n")) { const line = raw.trim(); if (!line.startsWith("|")) continue; // cells between the outer pipes const cells = line.split("|").slice(1, -1).map((c) => c.trim()); if (cells.length < 4) continue; const [id, ref, severity] = cells; // skip the header row and the |---|---| separator if (id === "rule-id" || /^:?-{2,}:?$/.test(id)) continue; if (!id) continue; rows.push({ id, ref, severity, provenance: cells[3] }); } return rows; } /** Section anchors the contract actually defines, for dangling-ref checks. */ interface Anchors { hasC1: boolean; hasC2: boolean; hasB: boolean; bRowCount: number; } function contractAnchors(contractText: string): Anchors { const hasC1 = /^##\s+C1\./m.test(contractText); const hasC2 = /^##\s+C2\./m.test(contractText); const hasB = /^##\s+B\./m.test(contractText); // §B rule rows look like: | 7 | **Regel** | … | let bRowCount = 0; for (const line of contractText.split("\n")) { if (/^\|\s*\d+\s*\|/.test(line)) bRowCount++; } return { hasC1, hasC2, hasB, bRowCount }; } /** Validate that every §-anchor in a ref resolves to a real contract section. */ function danglingRefIssues(id: string, ref: string, anchors: Anchors): RatifyIssue[] { const out: RatifyIssue[] = []; // §B-3, §C1, §C2 — ignore non-§ tokens like "hygiene" / "★" const tokens = ref.match(/§[A-Za-z]+\d*(?:-\d+)?/g) ?? []; for (const tok of tokens) { const bRule = tok.match(/^§B-(\d+)$/); if (tok === "§C1") { if (!anchors.hasC1) out.push({ kind: "dangling-ref", id, detail: `${ref}: §C1 mangler i kontrakten` }); } else if (tok === "§C2") { if (!anchors.hasC2) out.push({ kind: "dangling-ref", id, detail: `${ref}: §C2 mangler i kontrakten` }); } else if (bRule) { const n = Number(bRule[1]); if (!anchors.hasB) out.push({ kind: "dangling-ref", id, detail: `${ref}: §B mangler i kontrakten` }); else if (n > anchors.bRowCount) out.push({ kind: "dangling-ref", id, detail: `${ref}: §B-${n} finnes ikke (§B har ${anchors.bRowCount} regler)` }); } else if (tok === "§B" && !anchors.hasB) { out.push({ kind: "dangling-ref", id, detail: `${ref}: §B mangler i kontrakten` }); } } return out; } /** Core: compare RULES against a parsed manifest + contract anchors. */ export function ratify(contractText: string, rules: Rule[] = RULES): RatifyResult { const issues: RatifyIssue[] = []; const manifest = parseManifest(contractText); if (manifest === null) { return { contractPath: null, ruleCount: rules.length, manifestCount: 0, issues: [ { kind: "missing-from-manifest", id: "(manifest)", detail: "Fant ikke gate-bindings-manifestet () i §E. Kontrakten må bære manifestet for at bindingen skal eksistere.", }, ], ok: false, }; } const anchors = contractAnchors(contractText); const ruleById = new Map(rules.map((r) => [r.id, r])); const manById = new Map(); for (const row of manifest) { if (manById.has(row.id)) { issues.push({ kind: "extra-in-manifest", id: row.id, detail: "duplisert manifest-rad" }); continue; } manById.set(row.id, row); } // RULES → manifest (every enforced rule must be documented) for (const rule of rules) { const row = manById.get(rule.id); if (!row) { issues.push({ kind: "missing-from-manifest", id: rule.id, detail: `rules.ts håndhever «${rule.id}» (${rule.ref}, ${rule.severity}) uten en §E-manifest-rad`, }); continue; } if (row.ref !== rule.ref) { issues.push({ kind: "ref-mismatch", id: rule.id, detail: `manifest «${row.ref}» ≠ rules.ts «${rule.ref}»` }); } if (row.severity !== (rule.severity as Severity)) { issues.push({ kind: "severity-mismatch", id: rule.id, detail: `manifest «${row.severity}» ≠ rules.ts «${rule.severity}»`, }); } issues.push(...danglingRefIssues(rule.id, rule.ref, anchors)); } // manifest → RULES (every documented rule must be enforced) for (const row of manifest) { if (!ruleById.has(row.id)) { issues.push({ kind: "extra-in-manifest", id: row.id, detail: `§E dokumenterer «${row.id}» men rules.ts håndhever den ikke (regel fjernet uten å rydde manifestet?)`, }); } } return { contractPath: null, ruleCount: rules.length, manifestCount: manifest.length, issues, ok: issues.length === 0, }; } /** Read a contract file from disk and ratify it against RULES. */ export function ratifyFile(path: string, rules: Rule[] = RULES): RatifyResult { if (!existsSync(path)) { return { contractPath: path, ruleCount: rules.length, manifestCount: 0, issues: [ { kind: "missing-from-manifest", id: "(contract)", detail: `Fant ikke kontrakten: ${path}. Sett MASKINROMMET_CONTRACT eller gi stien som argument.`, }, ], ok: false, }; } const text = readFileSync(path, "utf8"); const r = ratify(text, rules); r.contractPath = path; return r; }