// canon-consistency.test.mjs // 1.8.0 "En kanon": deterministisk konsistensvakt som laaser F-a..F-i-konsolideringen // (review-2026-07-16.md §3). Offline, zero-dep, node:test. Hver case sporer til ett // review-funn og blir GROENN naar dens SISTE bidragende fil refererer kanon; RED-baseline // etableres foer konsumentene refererer kanon (TDD Iron Law). // // Norsk markdown-prosa matches via \uXXXX-escapes (test-kilde holdes ASCII-ren; unngaar // bash 3.2 set -u multibyte-krasj). Kollekter-til-array -> assert.deepEqual([], msg) som B1. // Moenster: tests/reference-integrity.test.mjs + tests/package-shape.test.mjs // Kilde: review-2026-07-16.md §3 (F-a..F-i, fil:linje) import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const REF = 'skills/okr-offentlig-sektor/references'; // --- delte parse-hjelpere (hver med egen sanity-case, jf. B1 refs.length >= 10) --- function readDoc(rel) { return readFileSync(join(ROOT, rel), 'utf8'); } function mdFiles(dir) { return readdirSync(join(ROOT, dir)) .filter((n) => n.endsWith('.md')) .sort() .map((n) => join(dir, n)); } const SKILL = 'skills/okr-offentlig-sektor/SKILL.md'; // R4 (review.md 231c53fc): kanon-skanningen skal dekke ALLE konsumentflater -- // references/, commands/, agents/ OG SKILL.md (som ligger ETT nivaa over references/). // Case (d) brukte allerede dette settet; (a)/(b) var smalere enn sine egne case-navn // lovet, saa etikett-drift i agents/ + SKILL.md var usynlig. Ett felles sett, ett sted. function canonScan() { return [...mdFiles(REF), ...mdFiles('commands'), ...mdFiles('agents'), SKILL]; } // Kontig. pipe-tabell-blokker: hver blokk = sammenhengende linjer som (trimmet) starter med '|'. function mdTables(body) { const tables = []; let cur = []; for (const line of body.split('\n')) { if (line.trim().startsWith('|')) { cur.push(line); } else if (cur.length) { tables.push(cur); cur = []; } } if (cur.length) tables.push(cur); return tables; } function linesMatching(body, re) { return body.split('\n').filter((l) => re.test(l)); } function headingsOf(body, re) { return body .split('\n') .filter((l) => re.test(l)) .map((l) => l.replace(/^#+\s+/, '').trim()); } // Confidence-tabell-signatur: en pipe-tabell hvis label-vokabular baerer alle tre nivaaer. // ALDRI match paa tokenet "50%" eller ordet "confidence" (unngaar falsk-positiv paa // score-aritmetikk / prosa). function isConfidenceTable(tableLines) { const txt = tableLines.join('\n'); return /on track/i.test(txt) && /at risk/i.test(txt) && /off track/i.test(txt); } // --- F-c: divergerende etikettSETT (R4 / review.md ccff16e1) --- // Et parallelt etikettsett gjenkjennes STRUKTURELT som en skraastrek-enumerasjon av // statusetiketter -- ikke som loepende prosa. "For KR i fare" og "Blokkert av eksterne // faktorer" er legitim prosa; det doktrinen forbyr er den parallelle SKALAEN // ("Paa sporet / I fare / Blokkert"). Kanoniske etiketter: okr-framework.md:389-392. const CANON_LABELS = ['on track', 'at risk', 'off track']; const DIVERGENT_LABELS = ['p\u00e5 sporet', 'i fare', 'blokkert', 'blocked']; // Magnitude-aksen (Hoey/Medium/Lav) er en ANNEN akse enn sannsynlighet. Kun flagget naar // linjen faktisk snakker om confidence -- en prioritetsskala med samme ord er legitim. const MAGNITUDE_SCALE = /(h\u00f8y|medium|lav)\s*\/\s*(h\u00f8y|medium|lav)/i; // Segmenter mellom skraastreker; et segment "er" en etikett naar det (etter stripping av // listemarkoer, utheving, klammer og emoji-hale) starter/slutter paa etiketten. function labelSegments(line) { return line .split('/') .map((s) => s.replace(/[*_`[\]()]/g, '').replace(/^[\s\-+]*(?:\d+\.)?\s*/, '').trim().toLowerCase()) .map((s) => { const all = [...CANON_LABELS, ...DIVERGENT_LABELS]; return all.find((l) => s === l || s.startsWith(`${l} `) || s.endsWith(` ${l}`)) ?? null; }); } // Returnerer de divergerende etikettene i en enumerasjon (>= 2 etikett-segmenter), ellers []. function divergentEnumeration(line) { const hits = labelSegments(line).filter(Boolean); if (hits.length < 2) return []; return hits.filter((h) => DIVERGENT_LABELS.includes(h)); } // ==================== parser-sanity (jf. B1) ==================== test('parser-sanity: headingsOf finner 10 rubrikk-dimensjoner', () => { const dims = headingsOf(readDoc(`${REF}/okr-quality-rubrics.md`), /^### /); assert.equal(dims.length, 10, `forventet 10 dims, fant ${dims.length}`); }); test('parser-sanity: mdTables finner confidence-tabellen i okr-framework.md', () => { const conf = mdTables(readDoc(`${REF}/okr-framework.md`)).filter(isConfidenceTable); assert.ok(conf.length >= 1, `forventet minst 1 confidence-tabell i framework, fant ${conf.length}`); }); test('parser-sanity: linesMatching finner check-in-linjer i okr-framework.md', () => { const hits = linesMatching(readDoc(`${REF}/okr-framework.md`), /check-?in/i); assert.ok(hits.length >= 2, `forventet >= 2 check-in-linjer, fant ${hits.length}`); }); // ==================== F-a..F-i konsistensvakt ==================== // (a) F-c confidence EN gang. RED til calculator-dedup (Step 6). // R4: skanne-settet utvidet fra references/+commands/ til canonScan() (som case (d)). test('(a) F-c: noeyaktig EN confidence-tabell, kanonisk i okr-framework.md', () => { const found = []; for (const f of canonScan()) { if (mdTables(readDoc(f)).some(isConfidenceTable)) found.push(f); } assert.deepEqual( found, [`${REF}/okr-framework.md`], `confidence-tabell skal finnes noeyaktig EN gang (okr-framework.md); fant: ${found.join(', ')}`, ); }); // (a2) F-c divergerende etikettSETT (R4 / review.md ccff16e1). En tabell-signatur alene // fanger ikke parallelle skalaer skrevet som bullet/mal-linje -- det var nettopp formen // driften overlevde i (agents/ + SKILL.md). test('(a2) F-c: ingen parallelle confidence-etikettsett utenfor kanon', () => { const violations = []; for (const f of canonScan()) { const body = readDoc(f); body.split('\n').forEach((line, i) => { const divergent = divergentEnumeration(line); if (divergent.length > 0) { violations.push(`${f}:${i + 1}: divergerende etikettsett (${divergent.join(', ')}): ${line.trim()}`); } if (/confidence/i.test(line) && MAGNITUDE_SCALE.test(line)) { violations.push(`${f}:${i + 1}: magnitude-skala paa confidence-aksen: ${line.trim()}`); } }); } assert.deepEqual( violations, [], `kanonisk sett = On Track / At Risk / Off Track (okr-framework.md:389-392):\n${violations.join('\n')}`, ); }); // --- F-d kadens x publikum (D1 / review.md 607313e3) --- // Den forrige vakten bandt kadens-adjektivet DIREKTE til check-in (\s+), og kunne derfor // ikke feile paa den KANONISKE tabellen: etter "Maanedlig" kommer "**" og en cellevegg, // aldri whitespace -- vakten var blind paa nettopp det artefaktet F-d produserer. ledWeekly // krevde i tillegg /review/, mens den kanoniske raden sier "statusgjennomgang". // To former, fordi strukturen er ulik: // PROSA -- kadens og binding i SAMME klausul. Klausul-splittingen er det som skiller // "Ukentlig 15-min check-in + maanedlig 30-min review" (korrekt dobbeltrytme, // okr-implementation.md:181) fra "Team-check-ins holdes maanedlig". // TABELL -- kadens-adjektivet ALENE i foerste celle; da baerer resten av raden publikum // og innhold, og bindingen gaar PAA TVERS av celleveggene. // Team er default; publikum-markoer ledelse/ledergruppe hever til ledelses-rytme. const WEEKLY = /\bukentlige?\b/i; const MONTHLY = /\bm\u00e5nedlige?\b/i; const CHECKIN = /check-?ins?\b/i; const LEAD_AUDIENCE = /ledelse|ledergruppe|ledelses/i; const LEAD_REVIEW = /review|statusgjennomgang|okr-status/i; // Klausul = setning / listeledd / celle / konjunksjon. Uten " og "-splittet blir "ukentlige // team check-ins og maanedlige reviews" (okr-implementation.md:376) en falsk positiv. function clauses(line) { return line.split(/[.,;|+]|\sog\s/i); } function cadenceProseViolation(line) { for (const c of clauses(line)) { if (MONTHLY.test(c) && CHECKIN.test(c) && !LEAD_AUDIENCE.test(c)) return 'team-check-in maanedlig'; if (WEEKLY.test(c) && LEAD_AUDIENCE.test(c) && LEAD_REVIEW.test(c)) return 'ledelsesrytme ukentlig'; } return null; } // Kadens-noekklet rad = foerste celle ER kadens-adjektivet (etter stripping av utheving). // Rader som bare BEGYNNER med et kadensord ("| Ukentlig check-in | Team OKR-eier |", // okr-implementation.md:490) er ikke kadens-noekklede og hoerer til prosa-formen. function cadenceRowViolation(row) { const cells = row .split('|') .map((c) => c.replace(/[*_`]/g, '').trim()) .filter((c) => c.length > 0); if (cells.length < 2) return null; const key = cells[0]; const rest = cells.slice(1).join(' '); if (/^m\u00e5nedlige?$/i.test(key) && CHECKIN.test(rest) && !LEAD_AUDIENCE.test(rest)) { return 'team-check-in maanedlig (kadens-rad)'; } if (/^ukentlige?$/i.test(key) && LEAD_AUDIENCE.test(rest)) { return 'ledelsesrytme ukentlig (kadens-rad)'; } return null; } // (b) F-d kadens strukturell (kadens x publikum). RED til kadens-konsumenter (Step 11-12). test('(b) F-d: ingen team-check-in maanedlig, ingen ledelsesrytme ukentlig', () => { const violations = []; for (const f of canonScan()) { // R4: utvidet fra references/+commands/ readDoc(f) .split('\n') .forEach((line, i) => { const why = cadenceProseViolation(line) ?? cadenceRowViolation(line); if (why) violations.push(`${f}:${i + 1}: ${why}: ${line.trim()}`); }); } assert.deepEqual(violations, [], `kadens-motsigelse (kadens x publikum):\n${violations.join('\n')}`); }); // (b2) D1 negative fixtures: de tre muterte kanoniske linjene review.md 607313e3 beviste at // (b) IKKE kunne se (okr-framework.md:54, :55, :58 flippet), + prosa-tvillingen av rad- // mutasjonen. Syntetiske strenger -- muterer aldri en ekte fil, men faller hvis moensteret // slakkes tilbake til noe som ikke naar den kanoniske tabellen. test('(b2) F-d: vakten flagger de muterte kanoniske kadens-linjene', () => { const mutations = [ '| **M\u00e5nedlig** | Team | Team-check-in (15 min): fremdrift, blokkere, neste steg |', '| **Ukentlig** | Ledelse/ledergruppe | OKR-statusgjennomgang: retning, prioritering |', 'Team-check-ins holdes **m\u00e5nedlig** (teamet selv).', 'Ledelsens OKR-statusgjennomgang holdes **ukentlig**.', ]; const unflagged = mutations.filter((l) => !(cadenceProseViolation(l) ?? cadenceRowViolation(l))); assert.deepEqual(unflagged, [], `mutert kanon slapp gjennom vakten:\n${unflagged.join('\n')}`); }); // (b3) D1 positive fixtures: de legitime formene vakten IKKE skal roere. Uten denne er (b2) // oppfylt av en vakt som flagger alt -- de to casene holder hverandre i sjakk. test('(b3) F-d: vakten flagger IKKE kanoniske/legitime kadens-linjer', () => { const legit = [ '| **Ukentlig** | Team | Team-check-in (15 min): fremdrift, blokkere, neste steg |', '| **M\u00e5nedlig** | Ledelse/ledergruppe | OKR-statusgjennomgang: retning, eskalering |', '| **Progresjon** | M\u00e5nedlig status | Oboard check-ins |', '| Ukentlig check-in | Team OKR-eier | x4 |', 'Ukentlig 15-min check-in + m\u00e5nedlig 30-min review er mindre enn mange bruker i dag', 'Etabler rytme med ukentlige team check-ins og m\u00e5nedlige reviews.', '5. **Continuous Tracking**: Ukentlig i team, m\u00e5nedlig til ledelsen', 'Tracking: Ukentlig team-check-in, m\u00e5nedlig status til ledergruppe', 'Team-check-ins holdes **ukentlig** (teamet selv).', 'Ledelsens OKR-statusgjennomgang holdes **m\u00e5nedlig**.', ]; const flagged = legit .map((l) => [l, cadenceProseViolation(l) ?? cadenceRowViolation(l)]) .filter(([, why]) => why); assert.deepEqual( flagged, [], `legitim kadens-form flagget:\n${flagged.map(([l, w]) => `${w}: ${l}`).join('\n')}`, ); }); // (c) F-e scoreband == rubrics. RED til kvalitet/kvalitetssjekker refererer rubrics:5 (Step 10). // 8-10-baand ELLER "Score 8-10"-kolonne = eget scoreband/inline-rubrikk (rubrics-toppbaand = 9-10). test('(c) F-e: kvalitet + kvalitetssjekker baerer ingen egen 8-10 scoreband/inline-rubrikk', () => { const files = ['commands/kvalitet.md', 'agents/kvalitetssjekker-agent.md']; const hits = []; for (const f of files) { for (const l of linesMatching(readDoc(f), /8-10/)) hits.push(`${f}: ${l.trim()}`); } assert.deepEqual(hits, [], `eget scoreband/inline-rubrikk (8-10) skal referere okr-quality-rubrics.md:5:\n${hits.join('\n')}`); }); // (d) F-g antipattern-antall drift-laas. GROENN i dag (B1 lukket 19->20). Aldri hardkod 20. test('(d) F-g: prosa-omtaler av antipattern-antall == dynamisk telling (drift-laas)', () => { const anti = readDoc(`${REF}/okr-antipatterns.md`); const antipatternCount = linesMatching(anti, /^### \d+\.\d+/).length; const categoryCount = linesMatching(anti, /^## \d+\./).length; assert.ok(antipatternCount > 0, `parser-sanity: fant ${antipatternCount} antipatterns`); assert.ok(categoryCount > 0, `parser-sanity: fant ${categoryCount} kategorier`); const scan = canonScan(); const drift = []; for (const f of scan) { for (const m of readDoc(f).matchAll(/(\d+)\s+antipatterns\b/gi)) { if (Number(m[1]) !== antipatternCount) drift.push(`${f}: "${m[0].trim()}" != ${antipatternCount}`); } } assert.deepEqual(drift, [], `antipattern-antall drift (telt = ${antipatternCount}):\n${drift.join('\n')}`); }); // (e) F-g kategori-navn. RED til Step 7 (analyse/trendanalytiker bruker de 5 ekte navnene). test('(e) F-g: analyse + trendanalytiker uten oppdiktede antipattern-kategorier', () => { const files = ['commands/analyse.md', 'agents/trendanalytiker-agent.md']; const invented = /Ambisjonsbalanse|Organisatoriske|Offentlig sektor-spesifikke|Offentlig-spesifikke/; const hits = []; for (const f of files) { for (const l of linesMatching(readDoc(f), invented)) hits.push(`${f}: ${l.trim()}`); } assert.deepEqual(hits, [], `oppdiktede kategorier (ekte: Formulering/Prosess/Kultur/Struktur/Ledelse):\n${hits.join('\n')}`); }); // (f) F-h binaer/milepael. RED til Step 10. antipatterns = 0 binaer (laas); kvalitet uten Ja/Nei-bullet. test('(f) F-h: antipatterns 0 binaer-omtaler (laas) + kvalitet uten Ja/Nei-binaerbullet', () => { const antiBinaer = linesMatching(readDoc(`${REF}/okr-antipatterns.md`), /bin\u00e6r/i); const kvalBinaer = linesMatching(readDoc('commands/kvalitet.md'), /Ja\/Nei|Bin\u00e6re KR/); const problems = [ ...antiBinaer.map((l) => `okr-antipatterns.md (skal ha 0 binaer): ${l.trim()}`), ...kvalBinaer.map((l) => `kvalitet.md antipattern-bullet: ${l.trim()}`), ]; assert.deepEqual(problems, [], `binaer/milepael-inkonsistens:\n${problems.join('\n')}`); }); // --- F-e rubrikk-dimensjoner (D2 / review.md 7579d59c) --- // agent.includes(d) er substring-containment: "Outcome" er subsumert av "Outcome-fokus", saa // den dimensjonen kunne slettes uten at vakten falt -- den ENE dimensjonen F-e-kriteriet // finnes for aa garantere var den ene vakten ikke kunne se. Dimensjonene er skrevet som // uthevede listeledd (- **Navn** ...) eller overskrifter; begge er token-grenser. function dimTokens(body) { const tokens = new Set(); for (const m of body.matchAll(/\*\*([^*\n]+)\*\*/g)) tokens.add(m[1].trim()); for (const h of headingsOf(body, /^#{2,6} /)) tokens.add(h); return tokens; } // (g) F-e agent 10 dims. RED til Step 10. Utled dimensjonsnavnene, ikke hardkod. test('(g) F-e: kvalitetssjekker-agent daekker alle 10 rubrikk-dimensjoner', () => { const dims = headingsOf(readDoc(`${REF}/okr-quality-rubrics.md`), /^### /); assert.equal(dims.length, 10, `parser-sanity: forventet 10 dims, fant ${dims.length}`); const tokens = dimTokens(readDoc('agents/kvalitetssjekker-agent.md')); const missing = dims.filter((d) => !tokens.has(d)); assert.deepEqual(missing, [], `kvalitetssjekker-agent mangler rubrikk-dims: ${missing.join(', ')}`); }); // (g2) D2 fixture: token-grensen skiller "Outcome" fra "Outcome-fokus". Med substring- // containment var denne dimensjonen utestbar -- sletting av agent-linjen forble groenn. test('(g2) F-e: dim-vakten skiller Outcome fra Outcome-fokus', () => { const tokens = dimTokens([' - **Outcome-fokus** - oensket tilstand', '### Datakilde'].join('\n')); assert.ok(tokens.has('Outcome-fokus'), 'uthevet listeledd skal gi token'); assert.ok(tokens.has('Datakilde'), 'overskrift skal gi token'); assert.ok(!tokens.has('Outcome'), '"Outcome" skal IKKE dekkes av "Outcome-fokus"'); }); // (h) F-a fjerning. RED til Step 7. Score->modenhet-avledning borte; sandbagging-trendsignal til stede. test('(h) F-a: ingen score->modenhet-avledning; sandbagging-trendsignal til stede', () => { const problems = []; for (const f of ['commands/analyse.md', 'agents/trendanalytiker-agent.md']) { const body = readDoc(f); // Avlednings-stigen identifiseres av de unike etikettene Utforsker + Skalering (ikke // "modenhet"-prosa, som selvrapportfeltet beholder). if (/Utforsker/i.test(body) && /Skalering/i.test(body)) { problems.push(`${f}: score->modenhet-avledning til stede (Utforsker+Skalering)`); } } const trend = readDoc('agents/trendanalytiker-agent.md'); if (!(/0\.85/.test(trend) && /sandbagging/i.test(trend))) { problems.push('trendanalytiker-agent.md: mangler sandbagging-trendsignal (>0.85 over 2+ sykluser)'); } assert.deepEqual(problems, [], `F-a auto-regel I:\n${problems.join('\n')}`); }); // (i) F-i kontekst. RED til Step 13. Spesifikke fjernede-betingelse-fraser, ALDRI bar "er listet". // D3 (review.md 029ef814): begge ekskluderingene er FJERNET. Kommentaren kalte filene // "allerede-fiksede"; commands/freshen-references.md var aldri fikset -- den falt utenfor // review-lista paa ni, saa vakten kunne PER KONSTRUKSJON aldri fange den ene levende // forekomsten den var skrevet for. commands/analyse.md-ekskluderingen var dead code // (fila matcher ingen av frasene). En permanent ekskludering er en vakt som ikke vokter. test('(i) F-i: ingen foreldede kontekst-injeksjons-fraser i commands/', () => { const phrases = /injiseres automatisk via hook|Hvis relevante filer er listet|aktive OKR-filer er listet|listet i system-kontekst/; const hits = []; for (const f of mdFiles('commands')) { for (const l of linesMatching(readDoc(f), phrases)) hits.push(`${f}: ${l.trim()}`); } assert.deepEqual(hits, [], `foreldet post-1.6.0 kontekst-blokk (bruk analyse.md:13-20-moenster):\n${hits.join('\n')}`); }); // (j) score-grenser doc-invariant (ingen kode beregner score). RED til Step 6. To distinkte asserts. test('(j) score-grenser: okr-calculator dokumenterer kapp [0,1.0] OG div-paa-null-regel', () => { const calc = readDoc(`${REF}/okr-calculator.md`); const problems = []; const hasCap = /kapp/i.test(calc) || /\[0,?\s*1[.,]0\]/.test(calc) || /maksimalt\s+1[.,]0/i.test(calc); const hasDivNull = /udefinert/i.test(calc) || /Target\s*==\s*Baseline/i.test(calc) || /0 m\u00e5lbare/i.test(calc); if (!hasCap) problems.push('mangler kapp-regel [0, 1.0] (over-/underoppnaaelse)'); if (!hasDivNull) problems.push('mangler div-paa-null-regel (Target==Baseline / 0 maalbare KR -> udefinert)'); assert.deepEqual(problems, [], `score-grenser doc-invariant:\n${problems.join('\n')}`); }); // (k) R2 (review.md 7ec575be): F-i-omskrivingen ga hver kommando en Kontekstbevissthet-blokk // som INSTRUERER `Glob`, men allowed-tools ble ikke utvidet tilsvarende -- et direktiv // kommandoen ikke kan utfoere. Strukturell invariant: nevner BODY verktoeyet, maa // frontmatter deklarere det. Case (i) grepper kun etter fjernede fraser og fanger ikke dette. function frontmatterAndBody(rel) { const raw = readDoc(rel); const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw); return m ? { fm: m[1], body: m[2] } : { fm: '', body: raw }; } test('(k) R2: kommandoer som instruerer Glob deklarerer Glob i allowed-tools', () => { const missing = []; let instructing = 0; for (const f of mdFiles('commands')) { const { fm, body } = frontmatterAndBody(f); if (!/\bGlob\b/.test(body)) continue; instructing += 1; const declared = /^allowed-tools:\s*(.+)$/m.exec(fm); const tools = (declared ? declared[1] : '').split(',').map((t) => t.trim()); if (!tools.includes('Glob')) { missing.push(`${f}: allowed-tools = ${declared ? declared[1].trim() : '(mangler)'}`); } } assert.ok(instructing >= 10, `parser-sanity: fant ${instructing} Glob-instruerende kommandoer`); assert.deepEqual( missing, [], `Glob instruert i body uten dekning i allowed-tools:\n${missing.join('\n')}`, ); });