linkedin-studio/scripts/contract-gate/src/ratify.ts
Kjell Tore Guttormsen ff3467de79 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>
2026-06-20 17:48:20 +02:00

229 lines
8 KiB
TypeScript

/**
* 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;
}