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>
229 lines
8 KiB
TypeScript
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;
|
|
}
|