From ff3467de793fd384119f6c1b0f2a37a444039e23 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 17:48:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20accumulation=20binding?= =?UTF-8?q?=20=E2=80=94=20ratify=20+=20=C2=A7E=20foldIns=20(fix=20#1=20sli?= =?UTF-8?q?ce=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 av fix #1 («rettelser fester seg»): binder rules.ts til den menneskelige sannhetskilden (skrivekontrakt §E), så en korreksjon gjort én gang håndheves for alltid i stedet for å gjenoppdages hver utgave (diagnose-gap 2). - ratify.ts: importerer RULES direkte (ingen skjør tekst-parsing), krever en bijeksjon mot §E gate-bindings-manifestet (id ↔ ref ↔ severity) + dangling- §-anker-sjekk. CLI: `--ratify [kontrakt]` / `npm run ratify`. Sti: arg > env MASKINROMMET_CONTRACT > default (relativt til modulen). - edition-state.template: foldIns[] fangst-kø (per-artikkel proveniens, pending|promoted|rejected, mekanisk→rules.ts / dømmekraft→§C2). - 12 nye tester (33 totalt), tsc rent. Live-kontrakt-integrasjonstest ratify-er det ekte §E-manifestet rent (10 regler bundet 1:1). Companion: skrivekontrakt §E (JA-promoter-prosedyre + manifest) i maskinrommet. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/edition-state.template.json | 2 + scripts/contract-gate/README.md | 39 +++- scripts/contract-gate/package.json | 3 +- scripts/contract-gate/src/cli.ts | 31 ++- scripts/contract-gate/src/ratify.ts | 229 +++++++++++++++++++++ scripts/contract-gate/tests/ratify.test.ts | 136 ++++++++++++ 6 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 scripts/contract-gate/src/ratify.ts create mode 100644 scripts/contract-gate/tests/ratify.test.ts diff --git a/config/edition-state.template.json b/config/edition-state.template.json index 799b070..7177a93 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -28,6 +28,7 @@ "personas": "Per-article resolved reader-persona set (input config), written/confirmed in Step 1. This makes personas configurable PER ARTIFACT, not just from one global plugin library: Step 1 resolves them in order — (1) already present here → use as-is; (2) /linkedin/personas.md (per-series file) → load; (3) plugin config/personas.local.md (or personas.template.md) library → select a subset; (4) none/insufficient → DEFINE interactively via AskUserQuestion. Exactly one entry has tier \"primær\" (the rest \"sekundær\"); «primær trumfer» on conflict. This set feeds BOTH the in-session sweep (Step 6) and the headless package (Step 6.5 / persona-reviewer). Each entry: { name, tier: \"primær\" | \"sekundær\", rolle, avkobler, overbeviser, ekspertise, sjargong, source: \"edition-state\" | \"series-file\" | \"plugin-library\" | \"interactive\" }. Default []: resolved on first Step 1.", "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 5–6.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].", + "foldIns": "Per-article accumulation queue (slice 2 of fix #1 — «rettelser fester seg»). Each correction KTG makes during an edition that is NOT yet a contract rule is captured here, then routed by the JA-promoter (maskinrommet/docs/skrivekontrakt.md §E) to a permanent home so it never has to be re-discovered: a MECHANICAL correction → a rules.ts gate rule (BLOCK/WARN) + a §B-row/§C1/§C2-box + a §E-manifest row (the contract-gate `ratify` check then asserts rules.ts ↔ §E-manifest stay in bijection); a JUDGMENT correction → a §C2-box only (stays with editorial-reviewer, no gate rule). Capture is per-article (provenance = which article surfaced it); promotion is series/contract-wide. Each entry: { id, date (ISO-8601), correction (what KTG corrected, near-verbatim), trigger (where/why it surfaced), classification: \"mechanical-block\" | \"mechanical-warn\" | \"judgment\" | null (set at the classify step), decision: \"pending\" | \"promoted\" | \"rejected\" (the JA-promoter outcome), ruleId: | null (set on promote for mechanical), note?: where a judgment/rejected fold-in landed }. Default []. Rejected fold-ins are kept for traceability, never deleted.", "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents." }, "schemaVersion": 1, @@ -80,6 +81,7 @@ "carousel": null }, "pivots": [], + "foldIns": [], "locked": false, "scheduled": null } diff --git a/scripts/contract-gate/README.md b/scripts/contract-gate/README.md index c603815..59c024d 100644 --- a/scripts/contract-gate/README.md +++ b/scripts/contract-gate/README.md @@ -4,9 +4,10 @@ Deterministisk gate som håndhever den **mekaniske** halvdelen av Maskinrommet-s (`maskinrommet/docs/skrivekontrakt.md`: §B de ti reglene, §C1 regelsjekk-gaten, §C2 prosa-håndverk) **før et utkast leveres til operatøren** — så de samme mekaniske korreksjonene aldri når KTG to ganger. -Dette er **slice 1** av fix #1 (§C2 bindende + akkumulerende). Den er bevisst _bindende_: ren -string/regex-evaluering, ingen AI, ingen nettverk — den kan ikke hoppes over eller snakkes rundt, -og kjører på millisekunder. +Dette er fix #1 (§C2 bindende + akkumulerende). **Slice 1** = denne gaten: den binder *utkast* til +reglene — bevisst _bindende_, ren string/regex-evaluering, ingen AI, ingen nettverk, så den ikke kan +hoppes over eller snakkes rundt, og kjører på millisekunder. **Slice 2** = `ratify` + §E: den binder +*reglene* (`rules.ts`) til den menneskelige sannhetskilden (kontrakten). Se [Akkumulering](#akkumulering-slice-2). ## Hva gaten gjør (og ikke gjør) @@ -56,7 +57,8 @@ cd scripts/contract-gate npm install # engangs (tsx + typescript) node --import tsx src/cli.ts # kvittering + exit 0/1 node --import tsx src/cli.ts --json # maskinlesbart -npm test # 21 tester +npm run ratify # slice 2: rules.ts ↔ §E-manifest i sync? +npm test # 33 tester (gate + ratify) ``` Exit: `0` = passert (ingen BLOCK, handoff tillatt), `1` = blokkert, `2` = bruksfeil. @@ -71,3 +73,32 @@ WARN feiler aldri gaten — de surfaces for KTGs dom. historisk artefakt, ikke gate-feil. - Versal-tic-redesignet (denylist av emfase-ord, ikke allowlist av akronymer) fjernet NAV/DFØ/NPM-false-positivene som ekte data avslørte. + +## Akkumulering (slice 2) + +Slice 1 binder *utkast* til reglene. Slice 2 binder *reglene* til kontrakten — så «rettelser fester +seg» blir mekanisk, ikke håpet-på. Diagnosens gap 2 («rettelser fester seg ikke») lukkes ved at hver +korreksjon KTG gjør får et varig, håndhevet hjem. + +**Ruta** (full prosedyre i `maskinrommet/docs/skrivekontrakt.md` §E): + +1. **Fang** korreksjonen i utgave-staten (`articles.NN.foldIns[]`) — ingenting mistes. +2. **Klassifiser:** mekanisk (eksakt frase / ord / markdown / telling) → `rules.ts`-regel + §B/§C1/§C2-rad; + dømmekraft → kun §C2-boks (blir hos `editorial-reviewer`, ingen gate-regel). +3. **JA-promoter:** KTG svarer JA/NEI. NEI → `rejected` (beholdes). JA → promotér. +4. **Promotér atomisk:** `rules.ts`-entry + kontrakt-rad + §E-manifest-rad (id·ref·severity·proveniens) + + merk fold-in `promoted`. +5. **Ratify:** `npm run ratify` MÅ være grønn før promoteringen teller. + +**Bindingen** (`src/ratify.ts`): importerer `RULES` direkte (ingen skjør tekst-parsing) og krever en +**bijeksjon** mot gate-bindings-manifestet i §E — hver regel ↔ nøyaktig én manifest-rad, med `ref` + +`severity` identisk, og hver `§`-anker en ekte seksjon. En regel i `rules.ts` uten manifest-rad (gaten +håndhever noe udokumentert), eller en manifest-rad uten regel (en «regel» ingen håndhever), feiler: + +``` +$ npm run ratify +✓ I sync — 10 regler bundet 1:1 til §E-manifestet (ref + severity stemmer). +``` + +Kontrakt-sti: argument > `MASKINROMMET_CONTRACT` > default (relativt til modulen). Mangler kontrakten +(fersk klon uten maskinrommet) → exit 2 med klar beskjed; den ekte-kontrakt-testen skippes da. diff --git a/scripts/contract-gate/package.json b/scripts/contract-gate/package.json index db0bd1a..6a20af9 100644 --- a/scripts/contract-gate/package.json +++ b/scripts/contract-gate/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc", "test": "node --import tsx --test tests/*.test.ts", - "start": "node --import tsx src/cli.ts" + "start": "node --import tsx src/cli.ts", + "ratify": "node --import tsx src/cli.ts --ratify" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/scripts/contract-gate/src/cli.ts b/scripts/contract-gate/src/cli.ts index 3d31360..cd31063 100644 --- a/scripts/contract-gate/src/cli.ts +++ b/scripts/contract-gate/src/cli.ts @@ -11,6 +11,8 @@ */ import { runGateFile } from "./gate.js"; +import { ratifyFile, defaultContractPath } from "./ratify.js"; +import type { RatifyResult } from "./ratify.js"; import type { GateResult, Violation } from "./types.js"; function printViolation(v: Violation): void { @@ -49,12 +51,39 @@ function report(r: GateResult): void { } } +function reportRatify(r: RatifyResult): void { + console.log("Gate-bindings-ratify (rules.ts ↔ §E-manifest)"); + console.log( + `Kontrakt: ${r.contractPath ?? "(ukjent)"} · ${r.ruleCount} regler / ${r.manifestCount} manifest-rader\n`, + ); + if (r.issues.length) { + console.log(`DRIFT (${r.issues.length}):`); + for (const i of r.issues) console.log(` [${i.kind}] ${i.id}\n → ${i.detail}`); + console.log(""); + console.log("✗ Ute av sync — rules.ts og §E-manifestet stemmer ikke. Rett før promotering teller."); + } else { + console.log(`✓ I sync — ${r.ruleCount} regler bundet 1:1 til §E-manifestet (ref + severity stemmer).`); + } +} + +function runRatify(args: string[], jsonOut: boolean): never { + const argPath = args.find((a) => !a.startsWith("--")); + const path = argPath ?? process.env.MASKINROMMET_CONTRACT ?? defaultContractPath(); + const result = ratifyFile(path); + if (jsonOut) console.log(JSON.stringify(result, null, 2)); + else reportRatify(result); + process.exit(result.ok ? 0 : 1); +} + function main(): void { const args = process.argv.slice(2); const jsonOut = args.includes("--json"); + if (args.includes("--ratify")) { + runRatify(args, jsonOut); + } const path = args.find((a) => !a.startsWith("--")); if (!path) { - console.error("Bruk: contract-gate [--json]"); + console.error("Bruk: contract-gate [--json] | contract-gate --ratify [kontrakt.md] [--json]"); process.exit(2); } const result = runGateFile(path); diff --git a/scripts/contract-gate/src/ratify.ts b/scripts/contract-gate/src/ratify.ts new file mode 100644 index 0000000..c56cbb3 --- /dev/null +++ b/scripts/contract-gate/src/ratify.ts @@ -0,0 +1,229 @@ +/** + * 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; +} diff --git a/scripts/contract-gate/tests/ratify.test.ts b/scripts/contract-gate/tests/ratify.test.ts new file mode 100644 index 0000000..8c3ecbd --- /dev/null +++ b/scripts/contract-gate/tests/ratify.test.ts @@ -0,0 +1,136 @@ +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"); + }); +});