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
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue