Adds scripts/contract-gate/ — a deterministic, AI-free gate that enforces the mechanical half of the Maskinrommet writing contract (§B red flags + §C1 regelsjekk) BEFORE operator handoff, so the same mechanical correction never reaches KTG twice. First slice of fix #1 (binding + accumulating); target re-scoped to close all 4 diagnosis gaps = sustainable newsletter workflow. - Structured ruleset as DATA (src/rules.ts) so accumulation (slice 2) appends. - 3 BLOCK (modell-strawman, series-thesis "er ikke modellen", _underscore_) + 7 WARN (judgment-needed). Block only on provable, ~zero-FP matches. - Code/frontmatter masking; line-accurate reporting; CLI receipt + exit code. - 21/21 tests (node:test), tsc clean. - Validated on 6 real editions: 0 BLOCK on published Del 2-5; caught a real Rule-10 violation in Del 6 (WIP) automatically. Versal-tic uses an emphasis-word denylist after real-data FPs (NAV/DFØ/NPM). Finding: the plugin already mirrors §A + §C2; §C1 was the unhomed gap this fills. Plugin structure lint unchanged (81/0/0); no new agents/commands/refs/skills.
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
import { describe, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { evaluate, runGateFile, mask } from "../src/gate.js";
|
|
import { RULES } from "../src/rules.js";
|
|
|
|
const ids = (vs: { ruleId: string }[]) => vs.map((v) => v.ruleId);
|
|
|
|
describe("contract-gate engine", () => {
|
|
describe("clean draft", () => {
|
|
test("a contract-compliant snippet passes with no blocks and no warns", () => {
|
|
const text =
|
|
"Jeg bruker KI hver dag og ser hvordan saksbehandlere henter ut potensialet.\n" +
|
|
"En kontraktsjurist sparte to dager. Verktøyet leverer det vi ber om, og " +
|
|
"ledelsen tar valget.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, true);
|
|
assert.equal(r.blocks.length, 0, `unexpected blocks: ${ids(r.blocks)}`);
|
|
assert.equal(r.warns.length, 0, `unexpected warns: ${ids(r.warns)}`);
|
|
});
|
|
});
|
|
|
|
describe("BLOCK: modell-stråmann (§B-3)", () => {
|
|
test("an exact forbidden phrase blocks handoff", () => {
|
|
const text = "Poenget er at det er ikke modellen som mangler, men kompetansen.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, false);
|
|
assert.ok(ids(r.blocks).includes("B3-modell-referansepunkt"));
|
|
});
|
|
|
|
test("'modellen er god nok' blocks", () => {
|
|
const r = evaluate("Mange tror modellen er god nok allerede.", RULES);
|
|
assert.equal(r.passed, false);
|
|
assert.ok(ids(r.blocks).includes("B3-modell-referansepunkt"));
|
|
});
|
|
});
|
|
|
|
describe("BLOCK: serie-tese (§B-10)", () => {
|
|
test("the meta-thesis 'er ikke modellen' blocks", () => {
|
|
const text = "Tesen er enkel: den knappe ressursen er ikke modellen.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, false);
|
|
assert.ok(ids(r.blocks).includes("B10-serie-tese"));
|
|
});
|
|
|
|
test("REGRESSION: positive concrete landing 'er den knappe ressursen' does NOT block", () => {
|
|
const text =
|
|
"Menneskene i organisasjonen er den knappe ressursen, ikke verktøyet.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, true, `unexpected blocks: ${ids(r.blocks)}`);
|
|
assert.ok(!ids(r.blocks).includes("B10-serie-tese"));
|
|
});
|
|
});
|
|
|
|
describe("BLOCK: _understrek_ emphasis (§C1)", () => {
|
|
test("underscore emphasis blocks", () => {
|
|
const r = evaluate("Dette er _viktig_ å forstå.", RULES);
|
|
assert.equal(r.passed, false);
|
|
assert.ok(ids(r.blocks).includes("understrek-emfase"));
|
|
});
|
|
|
|
test("snake_case identifiers in prose do NOT block", () => {
|
|
const r = evaluate("Variabelen LINKEDIN_STUDIO_DATA styrer roten.", RULES);
|
|
assert.ok(!ids(r.blocks).includes("understrek-emfase"));
|
|
});
|
|
});
|
|
|
|
describe("code masking", () => {
|
|
test("a forbidden phrase inside a fenced code block is NOT flagged", () => {
|
|
const text =
|
|
"Her er konfigen:\n```\nmodellen er god nok\n```\nEllers ren prosa.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, true, `unexpected blocks: ${ids(r.blocks)}`);
|
|
});
|
|
|
|
test("a forbidden phrase inside an inline code span is NOT flagged", () => {
|
|
const r = evaluate("Flagget `er ikke modellen` i koden.", RULES);
|
|
assert.ok(!ids(r.blocks).includes("B10-serie-tese"));
|
|
});
|
|
|
|
test("mask preserves line count and positions", () => {
|
|
const text = "linje1\n```\nkode\n```\nlinje5";
|
|
const masked = mask(text);
|
|
assert.equal(masked.split("\n").length, text.split("\n").length);
|
|
assert.equal(masked.length, text.length);
|
|
});
|
|
});
|
|
|
|
describe("WARN: non-blocking flags", () => {
|
|
test("a metaphor word warns but does not block", () => {
|
|
const text = "Det er et usynlig tak over hele organisasjonen.";
|
|
const r = evaluate(text, RULES);
|
|
assert.equal(r.passed, true);
|
|
assert.ok(ids(r.warns).includes("B2-metafor"));
|
|
assert.ok(!ids(r.blocks).includes("B2-metafor"));
|
|
});
|
|
|
|
test("high em-dash density warns", () => {
|
|
const text = "Ord — ord — ord — ord — ord.";
|
|
const r = evaluate(text, RULES);
|
|
assert.ok(ids(r.warns).includes("C2-tankestrek-tetthet"));
|
|
});
|
|
|
|
test("uppercase emphasis word warns", () => {
|
|
const dirty = evaluate("Dette er IKKE bra, selv om KI og IT er OK.", RULES);
|
|
assert.ok(ids(dirty.warns).includes("C2-versal-tic"));
|
|
});
|
|
|
|
test("REGRESSION: acronyms (NPM/DFØ/NAV) are NOT versal-tics", () => {
|
|
// Denylist semantics: only UPPERCASE emphasis words flag, never acronyms.
|
|
const r = evaluate("Verktøyet NPM og CLI, og DFØ og NAV, er nyttige.", RULES);
|
|
assert.ok(!ids(r.warns).includes("C2-versal-tic"), `flagged: ${ids(r.warns)}`);
|
|
});
|
|
|
|
test("uppercase 'MÅ' (with Å) is caught by the Unicode-aware pattern", () => {
|
|
const r = evaluate("Du MÅ gjøre dette nå.", RULES);
|
|
assert.ok(ids(r.warns).includes("C2-versal-tic"));
|
|
});
|
|
|
|
test("a plain acronym sentence raises no versal warn", () => {
|
|
const clean = evaluate("KI og IT samarbeider tett med NSM og EU.", RULES);
|
|
assert.ok(!ids(clean.warns).includes("C2-versal-tic"));
|
|
});
|
|
|
|
test("'KI-en' as subject warns only past the repetition threshold", () => {
|
|
const few = evaluate("KI-en gjør jobben.", RULES);
|
|
assert.ok(!ids(few.warns).includes("KI-en-subjekt"));
|
|
|
|
const many = evaluate(
|
|
"KI-en gjør X. KI-en gjør Y. KI-en gjør Z. KI-en gjør W.",
|
|
RULES,
|
|
);
|
|
assert.ok(ids(many.warns).includes("KI-en-subjekt"));
|
|
});
|
|
|
|
test("bare 'modell' is surfaced as a warn for per-occurrence review", () => {
|
|
const r = evaluate("En KI-modell er trent på data.", RULES);
|
|
assert.ok(ids(r.warns).includes("B3-modell-bar"));
|
|
assert.equal(r.passed, true);
|
|
});
|
|
});
|
|
|
|
describe("reporting", () => {
|
|
test("line numbers point at the original draft line", () => {
|
|
const text = "linje1\nlinje2\nDette er _viktig_ her.\nlinje4";
|
|
const r = evaluate(text, RULES);
|
|
const v = r.blocks.find((b) => b.ruleId === "understrek-emfase");
|
|
assert.ok(v);
|
|
assert.equal(v!.matches[0].line, 3);
|
|
});
|
|
|
|
test("count rules report observed and threshold", () => {
|
|
const r = evaluate("En modell, to modeller, tre modell-typer.", RULES);
|
|
const v = r.warns.find((w) => w.ruleId === "B3-modell-bar");
|
|
assert.ok(v);
|
|
assert.ok((v!.observed ?? 0) >= 3);
|
|
});
|
|
});
|
|
|
|
describe("runGateFile", () => {
|
|
test("reads a draft file and evaluates it", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "contract-gate-"));
|
|
try {
|
|
const p = join(dir, "draft.md");
|
|
writeFileSync(p, "Tesen: den knappe ressursen er ikke modellen.");
|
|
const r = runGateFile(p);
|
|
assert.equal(r.draftPath, p);
|
|
assert.equal(r.passed, false);
|
|
assert.ok(ids(r.blocks).includes("B10-serie-tese"));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
});
|