Tre maler i references/ lovet EN udifferensiert samlet score, i strid med separasjonsregelen i okr-framework.md: committed og aspirational maales mot ulike standarder og skal rapporteres hver for seg. Rot-aarsak: vektings-listen i okr-framework.md oppga "noen KR-er committed og andre aspirational" som en GRUNN TIL AA VEKTE -- seks linjer over seksjonen som forbyr aa aggregere de to i det hele tatt. Malene lovet et samlet tall fordi kanon sanksjonerte det. Bulleten er fjernet og erstattet av prosa som avgrenser vekting til innenfor EN type. Malene foelger naa generatorens form (lib/syklus-rapport.mjs): committed binaert mot kravet, aspirational som snitt over N KR. ASCII-boksenes bredde er bevart. (k)-vaktens skanne-sett utvidet fra commands/+agents/ til ogsaa references/. Eksklusjonen var KATALOG-BRED for aa skaane per-Objective-aggregeringen i kalkulatoren, og tok team-aggregatene med seg -- samme lekse som R4. Per-Objective gaar fortsatt klar, men paa MOENSTERET i stedet for paa katalogen. Ny vakt (k2) mot at kanon gjenoppretter sanksjonen. Strukturell, ikke semantisk: et kulepunkt i vektings-listen som navngir begge typene er en sanksjon i kraft av posisjon. Mutasjons-verifisert -- identisk ordlyd flyttet INN i listen feller vakten, som beviser at den maaler posisjon og ikke ordlyd. Suite 328 -> 329.
798 lines
39 KiB
JavaScript
798 lines
39 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 references/ + commands/ + agents/ -- ALLE flater som emitterer
|
|
// en rapportform. references/ sto foerst utenfor for aa skaane en ANNEN diskusjon
|
|
// (per-Objective-aggregering i kalkulatoren), men eksklusjonen var KATALOG-BRED og
|
|
// tok team-aggregatene med seg: tre maler i references/ lovet et samlet tall
|
|
// (meeting-guides.md, okr-calculator.md, okr-framework.md). Samme lekse som R4 --
|
|
// en vakt smalere enn sitt eget case-navn gjoer drift usynlig.
|
|
//
|
|
// Per-Objective-aggregeringen eksklusjonen skulle skaane gaar fortsatt klar, og
|
|
// gjoer det paa MOENSTERET i stedet for paa katalogen: "Beregn samlet score for et
|
|
// Objective med flere KR" (okr-calculator.md:122) har ingen verdi paa linjen, og
|
|
// "Objective total"-radene sier ikke "samlet score".
|
|
//
|
|
// 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(REF), ...mdFiles('commands'), ...mdFiles('agents')];
|
|
assert.ok(maler.length >= 30, `parser-sanity: forventet >= 30 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')}`,
|
|
);
|
|
});
|
|
|
|
// --- (k2) kanon sanksjonerer ikke det (k) forbyr (patch-lane #4) ---
|
|
//
|
|
// okr-framework.md listet "Naar noen KR-er er committed og andre aspirational" som
|
|
// en GRUNN TIL AA VEKTE -- seks linjer over seksjonen som forbyr aa aggregere de to
|
|
// typene i det hele tatt. Malene lovet et samlet tall fordi kanon sa at vekting var
|
|
// svaret paa blandet type. (k) alene ville rettet symptomet og latt kilden staa.
|
|
//
|
|
// Vakten er STRUKTURELL, ikke semantisk: den leser kulepunktlisten under
|
|
// "Naar bruke vekting:" -- en liste over TILFELLER DER MAN SKAL VEKTE -- og feller
|
|
// et punkt som navngir begge typene. Et punkt DER er en sanksjon i kraft av sin
|
|
// posisjon, uansett ordlyd. Den korrigerende prosaen ("vekting gjelder innenfor EN
|
|
// type; committed og aspirational rapporteres hver for seg") navngir ogsaa begge,
|
|
// men staar UTENFOR listen og gaar derfor klar. Et negasjons-moenster som skulle
|
|
// skille sanksjon fra forbud semantisk ville vaert nettopp den falske vakten S58
|
|
// ble felt av: den ville maalt ordlyd, ikke paastand.
|
|
const VEKTING_LISTE = /^\*\*N\u00e5r bruke vekting/;
|
|
|
|
test('(k2) vektings-listen oppgir ikke blandet committed/aspirational som vektings-grunn', () => {
|
|
const linjer = readDoc(join(REF, 'okr-framework.md')).split('\n');
|
|
const start = linjer.findIndex((l) => VEKTING_LISTE.test(l.trim()));
|
|
assert.ok(start >= 0, 'parser-sanity: fant ikke "Naar bruke vekting:"-listen');
|
|
|
|
// Listen er sammenhengende; foerste ikke-kulepunkt (her: blanklinjen foran
|
|
// **Anbefaling:**) avslutter den. Skiller listen seg fra overskriften senere,
|
|
// rydder sanity-asserten under opp i det hoeylytt i stedet for aa skanne tomt.
|
|
const punkter = [];
|
|
for (let i = start + 1; i < linjer.length; i += 1) {
|
|
const tekst = linjer[i].trim();
|
|
if (!tekst.startsWith('-')) break;
|
|
punkter.push({ nr: i + 1, tekst });
|
|
}
|
|
assert.ok(punkter.length >= 2, `parser-sanity: forventet >= 2 kulepunkter, fant ${punkter.length}`);
|
|
|
|
const violations = punkter
|
|
.filter((p) => /committed/i.test(p.tekst) && /aspirational/i.test(p.tekst))
|
|
.map((p) => `okr-framework.md:${p.nr}: ${p.tekst}`);
|
|
|
|
assert.deepEqual(
|
|
violations,
|
|
[],
|
|
`vekting oppgitt som svar paa blandet type -- motsier separasjonsregelen i samme fil:\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',
|
|
);
|
|
});
|
|
|
|
// Patch-lane #1: export-pdf.py bygger HTML med python-markdown, men uten
|
|
// `meta`-extensionen parses ikke YAML-frontmatter -- den renderes som broedtekst
|
|
// oeverst i PDF-en. Hver OKF-fil i treet BAERER frontmatter (type/resource/
|
|
// timestamp/...), saa enhver eksport av en bundle-fil lekker maskinlaget inn i
|
|
// et dokument som skal til sak-arkivet. Vakten laaser extensionen som naervaers-
|
|
// invariant paa den faktiske kallsiden, ikke paa prosa om den.
|
|
test('(p1) export-pdf.py aktiverer meta-extensionen (frontmatter skal ikke rendres)', () => {
|
|
const py = readDoc('scripts/export-pdf.py');
|
|
const m = py.match(/extensions=\[([^\]]*)\]/);
|
|
assert.ok(m, 'scripts/export-pdf.py: fant ingen extensions=[...] i markdown-kallet');
|
|
const exts = m[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
|
|
assert.ok(
|
|
exts.includes('meta'),
|
|
`python-markdown uten "meta" rendrer YAML-frontmatter som broedtekst; fant: ${exts.join(', ')}`,
|
|
);
|
|
});
|