feat(linkedin-studio): deterministic §B/§C1 contract binding-gate (fix #1 slice 1)
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.
This commit is contained in:
parent
9338454ea4
commit
58aebf05a9
10 changed files with 1329 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -57,6 +57,8 @@ docs/**/.session-state.local.json
|
|||
# Node.js
|
||||
scripts/analytics/node_modules/
|
||||
scripts/analytics/build/
|
||||
scripts/contract-gate/node_modules/
|
||||
scripts/contract-gate/build/
|
||||
|
||||
# --- session/local state ---
|
||||
# STATE.md is LOCAL-ONLY (gitignored): no private remote exists and STATE must
|
||||
|
|
|
|||
73
scripts/contract-gate/README.md
Normal file
73
scripts/contract-gate/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Contract-gate — §B/§C1 mekanisk binding-gate
|
||||
|
||||
Deterministisk gate som håndhever den **mekaniske** halvdelen av Maskinrommet-skrivekontrakten
|
||||
(`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.
|
||||
|
||||
## Hva gaten gjør (og ikke gjør)
|
||||
|
||||
Tre-nivå-modellen fra diagnosen — denne gaten er **nivå 1**:
|
||||
|
||||
| Nivå | Hva | Hvem | Hvor |
|
||||
|------|-----|------|------|
|
||||
| **1 — rent mekanisk** | §B/§C1-grep + tellinger | **denne gaten** (BLOCK/WARN) | her |
|
||||
| 2 — AI-mekanisk | §C1 pkt 1/8/9 | `voice-scrubber` (fikser før handoff) | Step 4 |
|
||||
| 3 — ekte dømmekraft | §C2 narrativ-arkitektur | `editorial-reviewer` (flagg til KTG) | Step 5.5 |
|
||||
|
||||
**Klassifiseringsprinsipp:** BLOCK bare det gaten kan _bevise_ med ~null false positives.
|
||||
Alt som krever dom → WARN (blokkerer ikke). Ekte dømmekraft / ekstern data er ikke her i det
|
||||
hele tatt (blir hos nivå 2/3).
|
||||
|
||||
### Regler (seed)
|
||||
|
||||
| Regel | Ref | Severity |
|
||||
|-------|-----|----------|
|
||||
| Modell-stråmann (4 eksakte fraser) | §B-3 | **BLOCK** |
|
||||
| Serie-tese «er ikke modellen» | §B-10 | **BLOCK** |
|
||||
| `_understrek_`-emfase | §C1 | **BLOCK** |
|
||||
| Metafor-ord (tak/lodd/reise/landskap) | §B-2 | WARN |
|
||||
| Bar «modell» (alle treff) | §B-3 | WARN |
|
||||
| «KI-en» som subjekt (>3×) | hygiene | WARN |
|
||||
| PS-blokk | §B-9 | WARN |
|
||||
| Tankestrek-tetthet (>1/50 ord) | §C2 | WARN |
|
||||
| Versal-tic (emfase-ord, ikke akronymer) | §C2 | WARN |
|
||||
| AI-tells (navngitte fraser) | §B-8 | WARN |
|
||||
|
||||
Reglene er **data** (`src/rules.ts`), ikke hardkodet logikk — det er det som gjør
|
||||
akkumuleringen (slice 2) ren: en ny regel = én rad i `skrivekontrakt.md` + én post her.
|
||||
|
||||
### Bevisst utsatt (dokumenterte gap, ikke stille utelatelser)
|
||||
|
||||
- **§C2 «postulerte tall»** (tall uten hedge/kilde) — krever hedge-nærhet-heuristikk med
|
||||
ikke-triviell false-positive-risiko.
|
||||
- **§C2 «ordrette gjentakelser»** (n-gram gjentatt >2×) — krever n-gram-analyse; kontraktens
|
||||
navngitte fraser er utgave-spesifikke.
|
||||
|
||||
Begge hører hjemme i en senere slice når heuristikken kan gjøres tillitsverdig.
|
||||
|
||||
## Kjøre
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Exit: `0` = passert (ingen BLOCK, handoff tillatt), `1` = blokkert, `2` = bruksfeil.
|
||||
WARN feiler aldri gaten — de surfaces for KTGs dom.
|
||||
|
||||
## Validering (6 ekte utgaver, juni 2026)
|
||||
|
||||
- **0 BLOCK** på publiserte Del 2–5 → false-blokkerer ikke ekte arbeid.
|
||||
- **Del 6 (WIP):** fanget et ekte Regel-10-brudd («Den knappe ressursen er ikke modellen» i
|
||||
brødteksten) automatisk — slog-drepingen i praksis.
|
||||
- **Del 1:** blokkerer på samme frase, men ble publisert **før** Regel 10 ble kodifisert (01.06) —
|
||||
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.
|
||||
566
scripts/contract-gate/package-lock.json
generated
Normal file
566
scripts/contract-gate/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
{
|
||||
"name": "linkedin-contract-gate",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkedin-contract-gate",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
scripts/contract-gate/package.json
Normal file
16
scripts/contract-gate/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "linkedin-contract-gate",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Deterministic §B/§C1 mechanical binding-gate for the Maskinrommet writing contract — runs BEFORE operator handoff so mechanical corrections never reach KTG twice",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"start": "node --import tsx src/cli.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
66
scripts/contract-gate/src/cli.ts
Normal file
66
scripts/contract-gate/src/cli.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI for the §B/§C1 mechanical binding-gate.
|
||||
*
|
||||
* node --import tsx src/cli.ts <utkast.md> [--json]
|
||||
*
|
||||
* Exit code: 0 if the gate passes (no BLOCK violations — handoff permitted),
|
||||
* 1 if BLOCKED, 2 on usage error. WARN violations never fail the gate; they are
|
||||
* surfaced for KTG's judgment. The text output is the "kvittering" the writing
|
||||
* contract asks for when a draft is delivered.
|
||||
*/
|
||||
|
||||
import { runGateFile } from "./gate.js";
|
||||
import type { GateResult, Violation } from "./types.js";
|
||||
|
||||
function printViolation(v: Violation): void {
|
||||
console.log(` [${v.ref}] ${v.ruleId}`);
|
||||
if (v.observed !== undefined) {
|
||||
const t = v.threshold !== undefined ? ` (terskel: ${v.threshold})` : "";
|
||||
console.log(` observert: ${v.observed}${t}`);
|
||||
}
|
||||
console.log(` → ${v.message}`);
|
||||
const shown = v.matches.slice(0, 5);
|
||||
for (const m of shown) console.log(` · L${m.line}: ${m.excerpt}`);
|
||||
if (v.matches.length > shown.length) {
|
||||
console.log(` · … +${v.matches.length - shown.length} til`);
|
||||
}
|
||||
}
|
||||
|
||||
function report(r: GateResult): void {
|
||||
console.log("§B/§C1 regelsjekk-gate");
|
||||
console.log(`Utkast: ${r.draftPath ?? "(stdin)"} · ${r.wordCount} ord\n`);
|
||||
if (r.blocks.length) {
|
||||
console.log(`BLOCK (${r.blocks.length}) — må fikses før handoff:`);
|
||||
r.blocks.forEach(printViolation);
|
||||
console.log("");
|
||||
}
|
||||
if (r.warns.length) {
|
||||
console.log(`WARN (${r.warns.length}) — krever din dom (blokkerer ikke):`);
|
||||
r.warns.forEach(printViolation);
|
||||
console.log("");
|
||||
}
|
||||
if (r.passed) {
|
||||
console.log(`✓ Gate passert — 0 block, ${r.warns.length} warn til vurdering.`);
|
||||
} else {
|
||||
console.log(
|
||||
`✗ BLOKKERT — ${r.blocks.length} block, ${r.warns.length} warn. Fiks block-ene og kjør på nytt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonOut = args.includes("--json");
|
||||
const path = args.find((a) => !a.startsWith("--"));
|
||||
if (!path) {
|
||||
console.error("Bruk: contract-gate <utkast.md> [--json]");
|
||||
process.exit(2);
|
||||
}
|
||||
const result = runGateFile(path);
|
||||
if (jsonOut) console.log(JSON.stringify(result, null, 2));
|
||||
else report(result);
|
||||
process.exit(result.passed ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
207
scripts/contract-gate/src/gate.ts
Normal file
207
scripts/contract-gate/src/gate.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Deterministic engine for the §B/§C1 mechanical binding-gate.
|
||||
*
|
||||
* Pure string/regex evaluation of a draft against a structured ruleset. No AI,
|
||||
* no network, no external state — so it is genuinely BINDING: it cannot be
|
||||
* skipped or talked around, and it runs in milliseconds. Mechanical violations
|
||||
* are caught here BEFORE the draft reaches KTG, so the same correction never
|
||||
* reaches him twice.
|
||||
*
|
||||
* Matching runs on a MASKED copy of the draft (frontmatter + code stripped to
|
||||
* spaces, line positions preserved) so identifiers/code never false-positive.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { GateResult, Match, Rule, Violation } from "./types.js";
|
||||
import { RULES } from "./rules.js";
|
||||
|
||||
export { RULES } from "./rules.js";
|
||||
|
||||
/**
|
||||
* Replace YAML frontmatter, fenced code blocks, and inline code spans with
|
||||
* spaces — preserving newlines, length, and therefore every offset/line number.
|
||||
*/
|
||||
export function mask(text: string): string {
|
||||
const chars = text.split("");
|
||||
const blank = (start: number, end: number) => {
|
||||
for (let i = start; i < end && i < chars.length; i++) {
|
||||
if (chars[i] !== "\n") chars[i] = " ";
|
||||
}
|
||||
};
|
||||
|
||||
// YAML frontmatter at the very top: ---\n … \n---
|
||||
if (text.startsWith("---\n")) {
|
||||
const close = text.indexOf("\n---", 3);
|
||||
if (close !== -1) {
|
||||
const lineEnd = text.indexOf("\n", close + 1);
|
||||
blank(0, lineEnd === -1 ? text.length : lineEnd);
|
||||
}
|
||||
}
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
const fence = /```[\s\S]*?```/g;
|
||||
while ((m = fence.exec(text)) !== null) blank(m.index, m.index + m[0].length);
|
||||
|
||||
const inline = /`[^`\n]*`/g;
|
||||
while ((m = inline.exec(text)) !== null) blank(m.index, m.index + m[0].length);
|
||||
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
function lineStarts(text: string): number[] {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "\n") starts.push(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** 1-based line of a character index. */
|
||||
function lineOf(starts: number[], index: number): number {
|
||||
let lo = 0;
|
||||
let hi = starts.length - 1;
|
||||
let ans = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (starts[mid] <= index) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return ans + 1;
|
||||
}
|
||||
|
||||
function excerptAt(original: string, index: number, len: number): string {
|
||||
const start = Math.max(0, index - 20);
|
||||
const end = Math.min(original.length, index + len + 20);
|
||||
return original.slice(start, end).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function countWords(masked: string): number {
|
||||
const m = masked.match(/[A-Za-zÆØÅæøå0-9]+/g);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
masked: string;
|
||||
original: string;
|
||||
starts: number[];
|
||||
}
|
||||
|
||||
function literalMatches(rule: Rule, ctx: Ctx): Match[] {
|
||||
const hay = ctx.masked.toLowerCase();
|
||||
const out: Match[] = [];
|
||||
for (const pat of rule.patterns) {
|
||||
const needle = pat.toLowerCase();
|
||||
if (!needle) continue;
|
||||
let from = 0;
|
||||
let idx: number;
|
||||
while ((idx = hay.indexOf(needle, from)) !== -1) {
|
||||
out.push({
|
||||
line: lineOf(ctx.starts, idx),
|
||||
excerpt: excerptAt(ctx.original, idx, needle.length),
|
||||
});
|
||||
from = idx + needle.length;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Run a rule's regex patterns; returns ALL matches (after the allow-filter). */
|
||||
function regexMatches(rule: Rule, ctx: Ctx, cap = Infinity): { matches: Match[]; count: number } {
|
||||
const baseFlags = rule.flags ?? "gi";
|
||||
const flags = baseFlags.includes("g") ? baseFlags : baseFlags + "g";
|
||||
const matches: Match[] = [];
|
||||
let count = 0;
|
||||
for (const pat of rule.patterns) {
|
||||
const re = new RegExp(pat, flags);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(ctx.masked)) !== null) {
|
||||
if (m[0].length === 0) {
|
||||
re.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
const trimmed = m[0].trim();
|
||||
count++;
|
||||
if (matches.length < cap) {
|
||||
const lead = m[0].length - m[0].trimStart().length;
|
||||
const at = m.index + lead;
|
||||
matches.push({
|
||||
line: lineOf(ctx.starts, at),
|
||||
excerpt: excerptAt(ctx.original, at, trimmed.length),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { matches, count };
|
||||
}
|
||||
|
||||
function evalRule(rule: Rule, ctx: Ctx, wordCount: number): Violation | null {
|
||||
let matches: Match[] = [];
|
||||
let observed: number | undefined;
|
||||
let threshold: number | undefined;
|
||||
|
||||
switch (rule.kind) {
|
||||
case "literal":
|
||||
matches = literalMatches(rule, ctx);
|
||||
break;
|
||||
case "regex":
|
||||
matches = regexMatches(rule, ctx).matches;
|
||||
break;
|
||||
case "count": {
|
||||
const { matches: ms, count } = regexMatches(rule, ctx, 8);
|
||||
observed = count;
|
||||
threshold = rule.threshold ?? 0;
|
||||
if (count > threshold) matches = ms;
|
||||
break;
|
||||
}
|
||||
case "density": {
|
||||
const { matches: ms, count } = regexMatches(rule, ctx, 8);
|
||||
const maxAllowed = Math.floor((wordCount / (rule.per ?? 50)) * (rule.threshold ?? 1));
|
||||
observed = count;
|
||||
threshold = maxAllowed;
|
||||
if (count > maxAllowed) matches = ms;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ref: rule.ref,
|
||||
severity: rule.severity,
|
||||
message: rule.message,
|
||||
matches,
|
||||
observed,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
|
||||
/** Evaluate raw draft text against a ruleset. */
|
||||
export function evaluate(
|
||||
text: string,
|
||||
rules: Rule[] = RULES,
|
||||
draftPath: string | null = null,
|
||||
): GateResult {
|
||||
const masked = mask(text);
|
||||
const ctx: Ctx = { masked, original: text, starts: lineStarts(text) };
|
||||
const wordCount = countWords(masked);
|
||||
|
||||
const blocks: Violation[] = [];
|
||||
const warns: Violation[] = [];
|
||||
for (const rule of rules) {
|
||||
const v = evalRule(rule, ctx, wordCount);
|
||||
if (!v) continue;
|
||||
(v.severity === "block" ? blocks : warns).push(v);
|
||||
}
|
||||
|
||||
return { draftPath, wordCount, blocks, warns, passed: blocks.length === 0 };
|
||||
}
|
||||
|
||||
/** Read a draft file from disk and evaluate it. */
|
||||
export function runGateFile(path: string, rules: Rule[] = RULES): GateResult {
|
||||
const text = readFileSync(path, "utf8");
|
||||
return evaluate(text, rules, path);
|
||||
}
|
||||
141
scripts/contract-gate/src/rules.ts
Normal file
141
scripts/contract-gate/src/rules.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Seed ruleset for the §B/§C1 mechanical binding-gate.
|
||||
*
|
||||
* Each rule transcribes a mechanically-checkable item from the Maskinrommet
|
||||
* writing contract (maskinrommet/docs/skrivekontrakt.md). The classification
|
||||
* principle (slice 1): BLOCK only what the gate can PROVE with ~zero false
|
||||
* positives (exact forbidden phrases, markdown syntax); WARN on grep-flags that
|
||||
* still need human/AI judgment (a word that *might* be a metaphor, a bare
|
||||
* "modell" that *might* be neutral). Judgment-only / external-data rules
|
||||
* (research/ traceability, term definitions, narrative architecture, the full
|
||||
* AI-tell list) are intentionally NOT here — they stay with voice-scrubber
|
||||
* (tier 2) and editorial-reviewer (tier 3).
|
||||
*
|
||||
* Deferred to a later slice (documented gaps, not silent omissions):
|
||||
* - §C2 "postulerte tall" (numbers without hedge/source) — needs a
|
||||
* hedge-proximity heuristic with non-trivial false-positive risk.
|
||||
* - §C2 "ordrette gjentakelser" (any n-gram repeated >2×) — needs n-gram
|
||||
* analysis; the contract's named phrases are edition-specific.
|
||||
*/
|
||||
|
||||
import type { Rule } from "./types.js";
|
||||
|
||||
export const RULES: Rule[] = [
|
||||
// ── BLOCK: provable, ~zero false positives ─────────────────────────────
|
||||
{
|
||||
id: "B3-modell-referansepunkt",
|
||||
ref: "§B-3 / §C1",
|
||||
severity: "block",
|
||||
kind: "literal",
|
||||
patterns: [
|
||||
"det er ikke modellen som",
|
||||
"ikke en bedre modell",
|
||||
"modellen er god nok",
|
||||
"uten å bytte modell",
|
||||
],
|
||||
message:
|
||||
"Modell-stråmann (regel 3, KTGs mest gjentatte korreksjon): ingen påstand om at modellen er problemet/for svak. Skriv om til vår evne, vilje og kompetanse til å hente ut potensialet.",
|
||||
},
|
||||
{
|
||||
id: "B10-serie-tese",
|
||||
ref: "§B-10 / §C1",
|
||||
severity: "block",
|
||||
kind: "literal",
|
||||
patterns: ["er ikke modellen"],
|
||||
message:
|
||||
"Serie-tesen / meta-tese (regel 10, korollar til regel 3): «… er ikke modellen» er den tause linsen, aldri en setning vi leverer. Positiv konkret landing («menneskene … er den knappe ressursen») er OK — den abstrakte meta-tesen er ikke.",
|
||||
},
|
||||
{
|
||||
id: "understrek-emfase",
|
||||
ref: "§C1 / hygiene",
|
||||
severity: "block",
|
||||
kind: "regex",
|
||||
// Markdown underscore-emphasis: opening _ at a boundary, closing _ before
|
||||
// whitespace/punctuation. snake_case (underscores between word chars) is
|
||||
// NOT matched, so identifiers in prose don't false-positive.
|
||||
patterns: ["(^|[\\s(])_[^_\\n]+_(?=[\\s).,!?:;]|$)"],
|
||||
message:
|
||||
"Emfase med _understrek_ rendrer rått (hygiene). Bruk **fet** (eller *kursiv* med stjerne).",
|
||||
},
|
||||
|
||||
// ── WARN: grep-flag, needs human/AI judgment ───────────────────────────
|
||||
{
|
||||
id: "B2-metafor",
|
||||
ref: "§B-2",
|
||||
severity: "warn",
|
||||
kind: "regex",
|
||||
patterns: ["\\b(tak|lodd|reise|landskap)\\b"],
|
||||
message:
|
||||
"Mulig metafor som bærer argumentet (regel 2). Bekreft at bruken er konkret/nøytral, ikke utvidet bildespråk («et usynlig tak» → «KI-en når ikke dataene»).",
|
||||
},
|
||||
{
|
||||
id: "B3-modell-bar",
|
||||
ref: "§B-3",
|
||||
severity: "warn",
|
||||
kind: "count",
|
||||
threshold: 0,
|
||||
patterns: ["\\bmodell"],
|
||||
message:
|
||||
"«modell» nevnt — sjekk at HVERT treff er nøytral faktabruk («en KI-modell er trent på …»), ikke modellen brukt som referansepunkt/stråmann (regel 3).",
|
||||
},
|
||||
{
|
||||
id: "KI-en-subjekt",
|
||||
ref: "hygiene ★",
|
||||
severity: "warn",
|
||||
kind: "count",
|
||||
threshold: 3,
|
||||
patterns: ["\\bKI-en\\b"],
|
||||
message:
|
||||
"«KI-en» gjentatt som handlende subjekt er dårlig norsk (språk-hygiene). Varier: «KI», «verktøyet», eller omskriv («data KI har tilgang til»).",
|
||||
},
|
||||
{
|
||||
id: "B9-PS",
|
||||
ref: "§B-9 / §C1",
|
||||
severity: "warn",
|
||||
kind: "regex",
|
||||
patterns: ["(^|\\n)\\s*P\\.?\\s?S\\.?\\b"],
|
||||
message:
|
||||
"PS/etter-closing-blokk (regel 9). Sjekk at den ikke åpner nytt begrep/rammeverk etter handlingskallet — leseren skal lukke på ett konkret handlingsvalg.",
|
||||
},
|
||||
{
|
||||
id: "C2-tankestrek-tetthet",
|
||||
ref: "§C2",
|
||||
severity: "warn",
|
||||
kind: "density",
|
||||
threshold: 1,
|
||||
per: 50,
|
||||
patterns: ["\\u2014"], // em dash —
|
||||
message:
|
||||
"Tankestrek-tetthet over ~1 per 50 ord (§C2). Bytt halvparten med komma eller punktum; innskudd-formelen «X — Y — Z» bør være sjelden.",
|
||||
},
|
||||
{
|
||||
id: "C2-versal-tic",
|
||||
ref: "§C2",
|
||||
severity: "warn",
|
||||
kind: "regex",
|
||||
// Case-sensitive, Unicode-aware (handles MÅ/NÅ/SÅ). DENYLIST, not allowlist:
|
||||
// flag only UPPERCASE forms of known Norwegian emphasis words — NOT every
|
||||
// 2+-uppercase run. Acronyms (NPM, DFØ, NAV) are not emphasis words, so they
|
||||
// never flag. (Real-data finding on Del 4/5: an acronym allowlist can't keep
|
||||
// up; the emphasis-word set is small and stable.)
|
||||
flags: "gu",
|
||||
patterns: [
|
||||
"(?<!\\p{L})(OG|IKKE|IKKJE|MEN|MÅ|SKAL|ALDRI|ALLTID|ALLE|INGEN|BARE|KUN|NÅ|SÅ|HELE|ENHVER)(?!\\p{L})",
|
||||
],
|
||||
message:
|
||||
"Versal-tic (§C2): emfase-ord i VERSALER midt i prosa («OG», «IKKE», «MEN») bryter kronikk-stilen. Bruk *kursiv* eller omformuler.",
|
||||
},
|
||||
{
|
||||
id: "B8-ai-tells",
|
||||
ref: "§B-8",
|
||||
severity: "warn",
|
||||
kind: "regex",
|
||||
patterns: [
|
||||
"la meg være ærlig",
|
||||
"i en verden der",
|
||||
"det handler ikke om .{1,40}? det handler om",
|
||||
],
|
||||
message:
|
||||
"AI-tell (regel 8). Fjern/omformuler. Full liste: auto-memory `no-ai-tell-phrases`.",
|
||||
},
|
||||
];
|
||||
64
scripts/contract-gate/src/types.ts
Normal file
64
scripts/contract-gate/src/types.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Types for the deterministic §B/§C1 mechanical binding-gate.
|
||||
*
|
||||
* The gate consumes a *structured* ruleset (data, not hardcoded logic) so that
|
||||
* the accumulation half (slice 2) can add a rule by appending one entry here +
|
||||
* one row to the live skrivekontrakt.md — the gate then enforces it next run.
|
||||
*
|
||||
* Source of truth for the seed rules: maskinrommet/docs/skrivekontrakt.md
|
||||
* (§B De ti reglene, §C1 Regelsjekk-gate, §C2 prosa-håndverk).
|
||||
*/
|
||||
|
||||
export type Severity = "block" | "warn";
|
||||
|
||||
export type RuleKind =
|
||||
| "literal" // case-insensitive substring(s) — robust for æøå phrases
|
||||
| "regex" // forbidden regex pattern
|
||||
| "count" // count regex occurrences; flag when total > threshold (default 0)
|
||||
| "density"; // count occurrences; flag when rate exceeds threshold per `per` words
|
||||
|
||||
export interface Rule {
|
||||
/** Stable id, e.g. "B3-modell-referansepunkt". */
|
||||
id: string;
|
||||
/** Contract reference for the report, e.g. "§B-3 / §C1". */
|
||||
ref: string;
|
||||
severity: Severity;
|
||||
kind: RuleKind;
|
||||
/** Literal substrings (kind=literal) or regex sources (regex/count/density). */
|
||||
patterns: string[];
|
||||
/** Operator-facing direction: what the violation is + what to do. */
|
||||
message: string;
|
||||
/** count: max allowed before flagging (default 0). density: max per `per` words. */
|
||||
threshold?: number;
|
||||
/** density window size in words (default 50). */
|
||||
per?: number;
|
||||
/** Regex flags for regex/count/density (default "gi"). */
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
/** 1-based line in the ORIGINAL draft. */
|
||||
line: number;
|
||||
/** Short single-line excerpt around the match (from the original text). */
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
ruleId: string;
|
||||
ref: string;
|
||||
severity: Severity;
|
||||
message: string;
|
||||
matches: Match[];
|
||||
/** Observed count/rate for count|density rules. */
|
||||
observed?: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface GateResult {
|
||||
draftPath: string | null;
|
||||
wordCount: number;
|
||||
blocks: Violation[];
|
||||
warns: Violation[];
|
||||
/** true iff there are no BLOCK violations — i.e. handoff is permitted. */
|
||||
passed: boolean;
|
||||
}
|
||||
178
scripts/contract-gate/tests/gate.test.ts
Normal file
178
scripts/contract-gate/tests/gate.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
16
scripts/contract-gate/tsconfig.json
Normal file
16
scripts/contract-gate/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "tests"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue