feat(linkedin-studio): accumulation binding — ratify + §E foldIns (fix #1 slice 2)

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) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 17:48:20 +02:00
commit ff3467de79
6 changed files with 434 additions and 6 deletions

View file

@ -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) <serie>/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 56.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: <rules.ts id> | 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
}

View file

@ -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 <utkast.md> # kvittering + exit 0/1
node --import tsx src/cli.ts <utkast.md> --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.

View file

@ -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",

View file

@ -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 <utkast.md> [--json]");
console.error("Bruk: contract-gate <utkast.md> [--json] | contract-gate --ratify [kontrakt.md] [--json]");
process.exit(2);
}
const result = runGateFile(path);

View file

@ -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
* `<!-- ratify:begin -->` and `<!-- ratify:end -->`. 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 `<!-- ratify:begin/end -->`.
* 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("<!-- ratify:begin -->");
const end = contractText.indexOf("<!-- ratify:end -->");
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 (<!-- ratify:begin/end -->) i §E. Kontrakten må bære manifestet for at bindingen skal eksistere.",
},
],
ok: false,
};
}
const anchors = contractAnchors(contractText);
const ruleById = new Map<string, Rule>(rules.map((r) => [r.id, r]));
const manById = new Map<string, ManifestRow>();
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;
}

View file

@ -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",
"<!-- ratify:begin -->",
"| rule-id | ref | severity | proveniens |",
"|---------|-----|----------|------------|",
...manifestRows,
"<!-- ratify:end -->",
].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");
});
});