729 lines
35 KiB
JavaScript
729 lines
35 KiB
JavaScript
// 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 11 rubrikk-dimensjoner', () => {
|
|
const dims = headingsOf(readDoc(`${REF}/okr-quality-rubrics.md`), /^### /);
|
|
assert.equal(dims.length, 11, `forventet 11 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')}`,
|
|
);
|
|
});
|
|
|
|
// --- (k) Separasjonsregelen i malene (D6 steg 14) ---
|
|
//
|
|
// okr-framework.md:557: committed og aspirational boer ikke blandes til ett
|
|
// aggregat -- de to typene rapporteres hver for seg. Generatoren
|
|
// (lib/syklus-rapport.mjs) haandhever det; malene gjorde det ikke. En regel med to
|
|
// lesninger er nettopp defektklassen S38-S40 kostet tre oekter.
|
|
//
|
|
// Skanne-settet er commands/ + agents/ -- MALENE pluginen emitterer. references/
|
|
// staar utenfor med vilje: der foeres en annen diskusjon (per-Objective-aggregering
|
|
// i kalkulatoren), og aa dra den inn her ville blandet to spoersmaal i en vakt.
|
|
//
|
|
// To former, fordi bruddet har to skrivemaater:
|
|
// ETIKETT -- "Samlet score" sammen med en VERDI paa linjen (tall, X-plassholder
|
|
// eller utfyllingsfelt). Verdien og etiketten bindes bevisst IKKE
|
|
// til hverandre posisjonelt: "Samlet score (committed +
|
|
// aspirational): 0.53" har noeyaktig det aggregatet vakten finnes
|
|
// for, og et moenster som krevde verdien rett etter etiketten slapp
|
|
// den gjennom (mutasjons-verifisert -- den gjorde det).
|
|
// INSTRUKS -- "samlet (vektet gjennomsnitt)", som ber leseren lage aggregatet.
|
|
//
|
|
// De andre aksene gaar klar: rubrikkens 0-10-skala har ingen verdi paa linjen,
|
|
// og det arkiverte datafeltet `samlet_score` er snake_case, ikke "samlet score".
|
|
// Mermaids akselabel rendrer det samme datafeltet i Title Case og unntas
|
|
// eksplisitt -- arkiv-kontrakten er en annen sak enn maleneS rapportform.
|
|
const AGGREGAT_ORD = /samlet\s+score/i;
|
|
const VERDISLOT = /\d[.,]\d|\d\.XX|_{3,}|\bX{1,2}\b/;
|
|
const MERMAID_AKSE = /^\s*[xy]-axis\b/;
|
|
const AGGREGAT_INSTRUKS = /\bsamlet\b[^\n]{0,25}vektet\s+gjennomsnitt/i;
|
|
|
|
test('(k) separasjonsregelen: ingen mal emitterer et udifferensiert samlet-aggregat', () => {
|
|
const maler = [...mdFiles('commands'), ...mdFiles('agents')];
|
|
assert.ok(maler.length >= 10, `parser-sanity: forventet >= 10 maler, fant ${maler.length}`);
|
|
|
|
const violations = [];
|
|
for (const f of maler) {
|
|
readDoc(f).split('\n').forEach((line, i) => {
|
|
// Differensiert = linjen navngir NOEYAKTIG EN type. En linje som nevner
|
|
// begge ("Samlet score (committed + aspirational)") er nettopp det
|
|
// udifferensierte aggregatet vakten finnes for -- den skal ikke slippe
|
|
// gjennom paa aa ha uttalt ordene.
|
|
if (/committed/i.test(line) !== /aspirational/i.test(line)) return;
|
|
if (MERMAID_AKSE.test(line)) return;
|
|
const etikett = AGGREGAT_ORD.test(line) && VERDISLOT.test(line);
|
|
if (etikett || AGGREGAT_INSTRUKS.test(line)) {
|
|
violations.push(`${f}:${i + 1}: ${line.trim()}`);
|
|
}
|
|
});
|
|
}
|
|
assert.deepEqual(
|
|
violations,
|
|
[],
|
|
`udifferensiert aggregat i mal (okr-framework.md:557 -- rapporter typene hver for seg):\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 11 dims. RED til Step 10. Utled dimensjonsnavnene, ikke hardkod.
|
|
test('(g) F-e: kvalitetssjekker-agent daekker alle 11 rubrikk-dimensjoner', () => {
|
|
const dims = headingsOf(readDoc(`${REF}/okr-quality-rubrics.md`), /^### /);
|
|
assert.equal(dims.length, 11, `parser-sanity: forventet 11 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"');
|
|
});
|
|
|
|
// --- (g3) dimensjonstall drift-laas paa tvers av de TRE flatene (D7 steg 15) ---
|
|
//
|
|
// Rubrikkfila er sannhetskilden; kvalitet.md og kvalitetssjekker-agent.md OMTALER
|
|
// antallet i prosa. Da steg 15 la til en 11. dimensjon var det nettopp de omtalene
|
|
// som kunne bli staaende paa "10" -- samme defektklasse som (d) F-g loeser for
|
|
// antipattern-antallet: ALDRI hardkod tallet i vakten, utled det.
|
|
//
|
|
// Tre teller-akser, fordi prosaen baerer alle tre: totalen ("alle 11 dimensjonene"),
|
|
// Objective-gruppa og Key Result-gruppa. En vakt paa bare totalen ville sluppet
|
|
// gjennom "de 5 Key Result-dimensjonene" etter at gruppa ble seks.
|
|
function rubricDims() {
|
|
const body = readDoc(`${REF}/okr-quality-rubrics.md`);
|
|
const groups = { Objective: [], 'Key Result': [] };
|
|
let cur = null;
|
|
for (const line of body.split('\n')) {
|
|
const g = line.match(/^## (Objective|Key Result)-dimensjoner\s*$/);
|
|
if (g) { cur = g[1]; continue; }
|
|
if (/^## /.test(line)) { cur = null; continue; }
|
|
const d = line.match(/^### (.+?)\s*$/);
|
|
if (d && cur) groups[cur].push(d[1]);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
test('(g3) dimensjonstall i kvalitet + kvalitetssjekker == faktisk antall i rubrikkfila', () => {
|
|
const groups = rubricDims();
|
|
const obj = groups.Objective.length;
|
|
const kr = groups['Key Result'].length;
|
|
const total = obj + kr;
|
|
|
|
// parser-sanity: gruppene skal finnes og summere til overskrifts-tellingen.
|
|
assert.ok(obj > 0 && kr > 0, `parser-sanity: fant ${obj} Objective- og ${kr} KR-dims`);
|
|
assert.equal(
|
|
total,
|
|
headingsOf(readDoc(`${REF}/okr-quality-rubrics.md`), /^### /).length,
|
|
'gruppe-summen skal daekke alle ###-dimensjoner (ingen dim utenfor de to gruppene)',
|
|
);
|
|
|
|
// Hver dimensjon skal ha NOEYAKTIG fem ankere -- ankerformen er det rubrikkfila lover.
|
|
const blocks = readDoc(`${REF}/okr-quality-rubrics.md`).split(/^### /m).slice(1);
|
|
const feilAnkertall = blocks
|
|
.map((b) => [b.split('\n')[0].trim(), (b.match(/^\d+\. \*\*Anker \d/gm) ?? []).length])
|
|
.filter(([, n]) => n !== 5)
|
|
.map(([navn, n]) => `${navn}: ${n} ankere`);
|
|
assert.deepEqual(feilAnkertall, [], `hver dimensjon skal ha fem ankere:\n${feilAnkertall.join('\n')}`);
|
|
|
|
// Drift-laasen: hvert tall prosaen oppgir maa matche den utledede tellingen.
|
|
//
|
|
// Aksene daekker BEGGE spraak. SKILL.md er engelsk etter policy (CLAUDE.md
|
|
// «SKILL.md: English»), saa en rent norsk akse ville vaert blind for
|
|
// «all 10 dimensions» selv med SKILL.md i fil-lista. Mutasjons-verifisert:
|
|
// med kun de norske aksene slapp nettopp den formen gjennom.
|
|
const akser = [
|
|
[/(\d+)\s+(?:dimensjonene?|dimensions?)\b/gi, total, 'totalt antall dimensjoner'],
|
|
[/\bObjective\s*\((\d+)\)/g, obj, 'Objective-gruppa'],
|
|
[/\bKey Result\s*\((\d+)\)/g, kr, 'Key Result-gruppa'],
|
|
[/\b(?:de|the)\s+(\d+)\s+Objective[-\s]dimensjonene?\b/gi, obj, 'Objective-gruppa'],
|
|
[/\b(?:de|the)\s+(\d+)\s+Key Result[-\s]dimensjonene?\b/gi, kr, 'Key Result-gruppa'],
|
|
[/\b(?:de|the)\s+(\d+)\s+Objective\s+dimensions?\b/gi, obj, 'Objective-gruppa (en)'],
|
|
[/\b(?:de|the)\s+(\d+)\s+Key Result\s+dimensions?\b/gi, kr, 'Key Result-gruppa (en)'],
|
|
];
|
|
// SKILL.md er med selv om den er ENGELSK og i dag ikke oppgir noe
|
|
// dimensjonstall. Den staar her av samme grunn som R4 la den inn i
|
|
// canonScan() (se :36-39): SKILL.md er nettopp flata drift har overlevd paa
|
|
// foer, og et engelsk «all 11 dimensions» ville vaert usynlig for enhver
|
|
// norsk grep. Aksene under matcher begge spraak.
|
|
const drift = [];
|
|
for (const f of ['commands/kvalitet.md', 'agents/kvalitetssjekker-agent.md', SKILL]) {
|
|
const body = readDoc(f);
|
|
for (const [re, forventet, hva] of akser) {
|
|
for (const m of body.matchAll(re)) {
|
|
if (Number(m[1]) !== forventet) drift.push(`${f}: "${m[0].trim()}" != ${forventet} (${hva})`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(drift, [], `dimensjonstall-drift (rubrikkfila = ${obj}+${kr}=${total}):\n${drift.join('\n')}`);
|
|
});
|
|
|
|
// --- (g5) kommando-antall drift-laas (D7 steg 19 / D6-beslutning 7) ---
|
|
//
|
|
// README.md:172 sa «all 14 commands» mens treet hadde 15 -- utdatert FOER denne
|
|
// oekten, og steg 19 gjorde den ett verre ved aa legge til /okr:arkivklar.
|
|
// Samme defektklasse som help.md/README.md-glippen i steg 12 (fikset i d2ac153):
|
|
// en levert kommandoform skal synes paa ALLE flater samtidig.
|
|
//
|
|
// Tallet utledes av commands/*.md -- aldri hardkodet, jf. (d) F-g.
|
|
test('(g5) prosa-omtaler av kommando-antall == faktisk antall commands/*.md', () => {
|
|
const antall = mdFiles('commands').length;
|
|
assert.ok(antall >= 10, `parser-sanity: fant ${antall} kommandofiler`);
|
|
|
|
// Tabellene som ER kommandolista skal ha en rad per fil.
|
|
for (const f of ['commands/help.md', 'CLAUDE.md']) {
|
|
const rader = readDoc(f).split('\n').filter((l) => /^\|\s*`\/okr:/.test(l)).length;
|
|
assert.equal(rader, antall, `${f}: ${rader} tabellrader != ${antall} kommandofiler`);
|
|
}
|
|
|
|
const drift = [];
|
|
for (const f of ['README.md', 'commands/help.md', 'CLAUDE.md', SKILL]) {
|
|
for (const m of readDoc(f).matchAll(/\b(\d+)\s+(?:commands|kommandoer)\b/gi)) {
|
|
if (Number(m[1]) !== antall) drift.push(`${f}: "${m[0].trim()}" != ${antall}`);
|
|
}
|
|
}
|
|
assert.deepEqual(drift, [], `kommando-antall drift (telt = ${antall}):\n${drift.join('\n')}`);
|
|
});
|
|
|
|
// --- (g4) guardrail som KR-designmoenster (D7 steg 16) ---
|
|
//
|
|
// Baseline foer steget: 0 treff paa "guardrail" i commands/, 2 i okr-antipatterns.md
|
|
// (:197 og :220). Begrepet fantes altsaa i kunnskapslaget, men naadde aldri malene
|
|
// pluginen faktisk emitterer -- samme uwirede-ledd-form som D6 steg 14 lukket.
|
|
// kvalitet.md skal dessuten KOBLE guardrail til leading/lagging-dimensjonen fra
|
|
// steg 15; uten koblingen blir sjekkpunktet et loest ord.
|
|
test('(g4) guardrail er wiret som KR-designmoenster i skriv + kvalitet', () => {
|
|
const mangler = [];
|
|
for (const f of ['commands/skriv.md', 'commands/kvalitet.md']) {
|
|
if (!/guardrail/i.test(readDoc(f))) mangler.push(`${f}: mangler guardrail-omtale`);
|
|
}
|
|
const kval = readDoc('commands/kvalitet.md');
|
|
const koblet = kval
|
|
.split('\n')
|
|
.some((l) => /guardrail/i.test(l) && /leading\/lagging|lagging/i.test(l));
|
|
if (!koblet) mangler.push('commands/kvalitet.md: guardrail ikke koblet til leading/lagging-dimensjonen');
|
|
assert.deepEqual(mangler, [], `guardrail-designmoenster (anker: okr-antipatterns.md:197,220):\n${mangler.join('\n')}`);
|
|
});
|
|
|
|
// (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')}`,
|
|
);
|
|
});
|
|
|
|
// --- (l) D4 steg 3: framework-seksjonene baerer hver sin egen kildeseksjon ---
|
|
// Fase D tilfoerer to navngitte seksjoner i okr-framework.md. Begge er domeneprosa
|
|
// bygget paa eksterne kilder, og kildedisiplinen krever at HVER av dem baerer sin
|
|
// egen kildeseksjon -- ikke en felles liste nederst, som gjoer det umulig aa se
|
|
// hvilken paastand som stoetter seg paa hva. Vakten er strukturell (seksjon finnes +
|
|
// seksjonen inneholder "### Kilder"), aldri paa ordlyd i selve prosaen.
|
|
const D4_FRAMEWORK_SECTIONS = [
|
|
'Fra strategi til OKR',
|
|
'Beyond Budgeting og target/forecast-separasjonen',
|
|
];
|
|
|
|
// Kropp mellom "## <heading>" og neste "## " -- brukes til aa binde kildeseksjonen
|
|
// til RIKTIG seksjon i stedet for aa lete i hele fila.
|
|
function sectionBody(body, heading) {
|
|
const lines = body.split('\n');
|
|
const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
if (start === -1) return null;
|
|
const rest = lines.slice(start + 1);
|
|
const end = rest.findIndex((l) => /^## /.test(l));
|
|
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
|
}
|
|
|
|
test('(l) D4: framework-seksjonene finnes og baerer hver sin kildeseksjon', () => {
|
|
const body = readDoc(`${REF}/okr-framework.md`);
|
|
const missing = [];
|
|
for (const heading of D4_FRAMEWORK_SECTIONS) {
|
|
const sec = sectionBody(body, heading);
|
|
if (sec === null) {
|
|
missing.push(`seksjon mangler: ## ${heading}`);
|
|
continue;
|
|
}
|
|
if (!/^### Kilder\s*$/m.test(sec)) {
|
|
missing.push(`seksjon uten egen kildeseksjon: ## ${heading}`);
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`fase D-seksjoner i okr-framework.md:\n${missing.join('\n')}`,
|
|
);
|
|
});
|
|
|
|
// (l2) BBIs eget materiale navngir det siste prosessprinsippet "Coordination";
|
|
// utbredte sekundaerkilder kaller det "Rhythm". Diskrepansen er reell, og loesningen
|
|
// er aa sitere ORDLYD framfor kortnavn. Vakten laaser at sekundaerkilde-navnet aldri
|
|
// snieker seg inn i kanon.
|
|
test('(l2) D4: BBI-prinsipper siteres paa ordlyd, ikke paa det omstridte kortnavnet', () => {
|
|
const offenders = linesMatching(readDoc(`${REF}/okr-framework.md`), /Rhythm/);
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'BBIs egen ordlyd navngir prinsippet "Coordination"; "Rhythm" er sekundaerkilde-navngiving',
|
|
);
|
|
});
|
|
|
|
// --- (m) D4 steg 4: KOSTRA som indikatorkilde i den kommunale styringslinjen ---
|
|
// Den kommunale fila hadde 0 treff paa KOSTRA foer fase D, samtidig som KOSTRA er
|
|
// den faktiske indikatorkilden kommunale KR-er henter fra. Vakten laaser at
|
|
// seksjonen finnes og baerer sin egen kildeseksjon, samme form som (l).
|
|
test('(m) D4: kommunal-styring baerer KOSTRA-seksjon med egen kildeseksjon', () => {
|
|
const body = readDoc(`${REF}/okr-kommunal-styring.md`);
|
|
const missing = [];
|
|
const sec = sectionBody(body, 'KOSTRA som indikatorkilde');
|
|
if (sec === null) {
|
|
missing.push('seksjon mangler: ## KOSTRA som indikatorkilde');
|
|
} else if (!/^### Kilder\s*$/m.test(sec)) {
|
|
missing.push('KOSTRA-seksjonen mangler egen kildeseksjon');
|
|
}
|
|
assert.deepEqual(missing, [], `KOSTRA-seksjon i okr-kommunal-styring.md:\n${missing.join('\n')}`);
|
|
});
|
|
|
|
// (m2) SEKTORAVGRENSNING. DFO-notat 2026:2 "Styring i staten" er eksplisitt avgrenset
|
|
// mot kommunal sektor. Notatet er en rik kilde som brukes flere steder i kanon, og
|
|
// nettopp derfor er det lett aa dra det med inn i den kommunale fila ved et uhell.
|
|
// Vakten er en ren fraavaers-invariant paa notatets identifikator.
|
|
test('(m2) D4: kommunal-styring siterer ikke DFO-notat 2026:2 (sektoravgrensning)', () => {
|
|
const offenders = linesMatching(
|
|
readDoc(`${REF}/okr-kommunal-styring.md`),
|
|
/2026:2|dfo-notat-2026-2/i,
|
|
);
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'DFO-notat 2026:2 er avgrenset mot kommunal sektor og kan ikke siteres her',
|
|
);
|
|
});
|
|
|
|
// --- (n) D5 steg 6: KR-datakontrakten (beslutning B-1) er dokumentert i skriv-malen ---
|
|
// Generatorene i lib/syklus-*.mjs leser KR-tall fra `krN_`-noekler i okr-*.md sin
|
|
// frontmatter. Kontrakten har ingen skjema-fil aa haandheve seg mot -- den haandheves
|
|
// av at DEFINISJONSFLATEN (skriv-malen) dokumenterer den, siden /okr:skriv er stedet
|
|
// tallene oppstaar. Vakten binder de to sidene sammen: endres kontrakten i lib/ uten
|
|
// at malen foelger etter, produserer brukeren filer generatoren ikke kan lese.
|
|
const KR_KONTRAKT_NOEKLER = ['navn', 'baseline', 'target', 'naa', 'type'];
|
|
|
|
test('(n) D5: skriv.md dokumenterer alle fem krN_-noeklene i KR-datakontrakten', () => {
|
|
const doc = readDoc('commands/skriv.md');
|
|
const missing = KR_KONTRAKT_NOEKLER.filter(
|
|
(k) => !new RegExp(`kr(?:N|\\d+)_${k}\\b`).test(doc),
|
|
);
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`KR-datakontrakten (B-1) mangler noekler i commands/skriv.md: ${missing.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
// B-2: score BEREGNES, lagres aldri. En `krN_score`-noekkel i malen ville skapt en
|
|
// andre sannhetskilde som kan drifte fra (naa - baseline) / (target - baseline).
|
|
// Fravaers-invariant paa NOEKKELFORMEN slik lib/frontmatter.mjs:29 leser den
|
|
// (`^\s*key:`) -- ikke paa ordet "score", som malen legitimt bruker om
|
|
// moonshot/roofshot-kalibrering (:124), og ikke paa prosa som FORBYR noekkelen.
|
|
// Det er noekkelen i frontmatter som skaper den andre sannhetskilden; en omtale
|
|
// av den gjoer det ikke.
|
|
test('(n2) D5: skriv.md introduserer ingen krN_score-noekkel (score lagres aldri)', () => {
|
|
const offenders = linesMatching(readDoc('commands/skriv.md'), /^\s*kr(?:N|\d+)_score\s*:/m);
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'score skal beregnes fra baseline/target/naa, aldri lagres som egen noekkel',
|
|
);
|
|
});
|