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:
parent
58aebf05a9
commit
ff3467de79
6 changed files with 434 additions and 6 deletions
|
|
@ -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 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: <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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
229
scripts/contract-gate/src/ratify.ts
Normal file
229
scripts/contract-gate/src/ratify.ts
Normal 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;
|
||||
}
|
||||
136
scripts/contract-gate/tests/ratify.test.ts
Normal file
136
scripts/contract-gate/tests/ratify.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue