fix(okr): skjerp separasjonsvakten og lukk tre defekter i D6-leveransen

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 07:09:35 +02:00
commit d2ac153a68
5 changed files with 100 additions and 14 deletions

View file

@ -175,12 +175,21 @@ test('(a2) F-c: ingen parallelle confidence-etikettsett utenfor kanon', () => {
// 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: <verdi>" i en malblokk. 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".
// 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.
// En linje som navngir typen er per definisjon differensiert og gaar klar.
const AGGREGAT_ETIKETT = /samlet\s+score[:*\s]*[[\]0-9X_]/i;
//
// 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', () => {
@ -190,8 +199,14 @@ test('(k) separasjonsregelen: ingen mal emitterer et udifferensiert samlet-aggre
const violations = [];
for (const f of maler) {
readDoc(f).split('\n').forEach((line, i) => {
if (/committed|aspirational/i.test(line)) return;
if (AGGREGAT_ETIKETT.test(line) || AGGREGAT_INSTRUKS.test(line)) {
// 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()}`);
}
});

View file

@ -383,6 +383,33 @@ for (const [navn, bygger] of FORMER) {
});
}
// Avviks-seksjonen viser til «forventningen til aspirational under». Den
// seksjonen finnes bare naar syklusen HAR aspirational-KR. En syklus med bare
// committed -- fullt lovlig, og typisk for en ren etterlevelses-syklus -- ville
// faatt et styringsdokument som peker paa en seksjon som ikke er der. Fixturen
// kan ikke fange det; den har begge typene.
const KUN_COMMITTED = {
id: 'T-kun-committed',
okrer: [{
fil: 'okr-etterlevelse.md',
tittel: 'Etterlevelse',
krer: [{ n: 1, navn: 'Palagte tilsyn gjennomfoert (antall)', baseline: 0, target: 12, naa: 9, type: 'committed' }],
}],
};
for (const [navn, bygger] of FORMER) {
test(`(10f/${navn}) ingen henvisning til en aspirational-seksjon som ikke finnes`, () => {
const md = bygger(KUN_COMMITTED, { naa: NAA });
const viser = /forventningen til aspirational under/i.test(md);
const finnes = /^##+\s+Aspirational/m.test(md);
assert.equal(
viser && !finnes,
false,
'avviks-seksjonen viser til en aspirational-seksjon rapporten ikke har',
);
});
}
// Generatoren handhever regelen; governance-fila baerer begrunnelsen. Staar
// begrunnelsen bare i koden, kan en leser overproeve vakten uten aa moete
// argumentet -- og det er nettopp en Riksrevisjon-vendt leser som vil sporre.
@ -586,6 +613,35 @@ test('(7i) CLI: etatsstyring skrives til sin egen fil', async (t) => {
assert.equal(lesSyklus(dir).okrer.length, 2, 'rapporten ble lest som en OKR');
});
// Fixturens historikk er FLAT prosa-filer. Den EKTE formen kommer fra
// /okr:oppsett arkiver (commands/oppsett.md:514-518), som lager BEGGE deler:
// katalogen `historikk/[id]/` med syklusens filer, OG den flate
// `historikk/[id]-resultater.md` med sammendraget. Sammendraget er riktig
// granularitet for et flerarig perspektiv; katalogen er raamaterialet under.
// Uten denne testen ville fixtur, kode og (7h) vaert tre artefakter som er enige
// med hverandre -- og ingen av dem den kommandoen som faktisk skriver treet.
test('(7j) historikk leses fra arkiveringens EKTE form: sammendragsfil, ikke katalog', async (t) => {
const { rmSync, mkdirSync, mkdtempSync, writeFileSync, copyFileSync, readdirSync, readFileSync: les } = await import('node:fs');
const { tmpdir } = await import('node:os');
const rot = mkdtempSync(join(tmpdir(), 'okr-arkiv-'));
const dir = join(rot, 'syklus', 'T1-2026');
const hist = join(rot, 'historikk');
mkdirSync(dir, { recursive: true });
mkdirSync(join(hist, 'T3-2025'), { recursive: true });
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
// Kopien av syklusen ligger i katalogen; sammendraget flatt ved siden av.
writeFileSync(join(hist, 'T3-2025', 'okr-gammel.md'), ['---', 'type: OKR', 'title: Gammel OKR', '---', ''].join('\n'));
writeFileSync(join(hist, 'T3-2025-resultater.md'), ['---', 'type: Retrospektiv', 'title: Resultater T3-2025', '---', '# Resultater T3-2025', ''].join('\n'));
t.after(() => rmSync(rot, { recursive: true, force: true }));
assert.equal((await kjoerCli([dir, 'arsrapport'])).code, 0);
const kropp = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(kropp, /Resultater T3-2025/, 'sammendragsfila fra arkiveringen ble ikke funnet');
// Den arkiverte syklus-katalogen er raamateriale, ikke en post i det flerarige
// perspektivet -- og `[id]/` ender ikke paa .md, saa den filtreres bort som seg selv.
assert.doesNotMatch(kropp, /Gammel OKR/, 'generatoren gikk inn i den arkiverte syklus-katalogen');
});
// Idempotens: rapporten skal kunne kjoeres om igjen uten aa endre bundlen, og
// uten aa bli lest som en OKR neste gang (filnavnet starter ikke med okr-).
test('(7f) CLI: to kjoeringer gir byte-identisk fil', async (t) => {