feat(okr): aarsrapport del III-generator

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 06:49:59 +02:00
commit 77031b0c9a
3 changed files with 384 additions and 65 deletions

View file

@ -56,6 +56,11 @@ function krRad(kr, erCommitted) {
return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(kr, erCommitted)} | |`;
}
const krTabell = (krer, erCommitted) => [
...TABELLHODE,
...krer.map((kr) => krRad(kr, erCommitted)),
];
// Ett avsnitt per OKR, med KR-ene som tabellrader. Rekkefolgen arves fra
// lesSyklus (sortert filnavn) og er del av determinisme-kontrakten.
function seksjon(okrer, type, erCommitted) {
@ -65,9 +70,7 @@ function seksjon(okrer, type, erCommitted) {
const krer = okr.krer.filter((kr) => kr.type === type);
if (krer.length === 0) continue;
antallKr += krer.length;
linjer.push(`### ${okr.tittel}`, '', ...TABELLHODE);
for (const kr of krer) linjer.push(krRad(kr, erCommitted));
linjer.push('');
linjer.push(`### ${okr.tittel}`, '', ...krTabell(krer, erCommitted), '');
}
return { linjer, antallKr };
}
@ -94,6 +97,36 @@ function aspirationalSnitt(okrer) {
return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`;
}
// Felles inngangsvakt for alle rapportformene. En form som stille rapporterte
// en tom syklus ville produsert et styringsdokument uten innhold, som ser
// komplett ut.
function krevSyklus(syklus, hvor) {
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
throw new Error(`${hvor}: forventet en syklus fra lesSyklus()`);
}
if (syklus.okrer.length === 0) {
throw new Error(`${hvor}: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
}
}
// Skala- og confidence-avsnittet er felles for alle formene, ikke fordi det
// sparer linjer, men fordi de tre governance-invariantene ikke kan gjelde bare
// den ene rapporten mottakeren tilfeldigvis leser.
const SKALAFORKLARING = [
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
// trekker feil konklusjon om baade ambisjonsniva og resultat.
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
'',
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
'',
];
const klokke = (opts) => opts.naa || process.env.OKR_NOW || new Date().toISOString();
/**
* Bygger tertialrapporten for en syklus lest av lesSyklus().
*
@ -103,14 +136,9 @@ function aspirationalSnitt(okrer) {
* @returns {string} markdown
*/
export function tertialrapport(syklus, opts = {}) {
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
throw new Error('tertialrapport: forventet en syklus fra lesSyklus()');
}
if (syklus.okrer.length === 0) {
throw new Error(`tertialrapport: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
}
krevSyklus(syklus, 'tertialrapport');
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
const naa = klokke(opts);
const committed = seksjon(syklus.okrer, 'committed', true);
const aspirational = seksjon(syklus.okrer, 'aspirational', false);
@ -124,16 +152,7 @@ export function tertialrapport(syklus, opts = {}) {
'',
`Generert: ${naa}`,
'',
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
// trekker feil konklusjon om baade ambisjonsniva og resultat.
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
'',
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
'',
...SKALAFORKLARING,
];
if (committed.antallKr > 0) {
@ -164,3 +183,116 @@ export function tertialrapport(syklus, opts = {}) {
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}
// --- Aarsrapport del III (steg 11) ---
//
// Del III «Aarets aktiviteter og resultater» er hovedplassen for OKR i den
// statlige aarsrapporten (okr-offentlig-governance.md:125-133).
//
// ANTIPATTERNET generatoren maa unngaa, ordrett fra primaerkilden --
// Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av
// Norsk institutt for biooekonomi (NIBIO)», del av Dokument 1 (2020-2021) s. 107:
// framstillingen «synliggjoer i mindre grad NIBIOs analyser av maaloppnaaelsen og
// framstaar som et oeyeblikksbilde av NIBIOs aktiviteter og resultater». Saken ble
// senere avsluttet etter forbedringer i maal- og resultatstyringen (gjengitt i
// DFOe-notat 2026:2 s. 48) -- funnet er en historisk dokumentert svakhet
// forvaltningen har rettet, ikke gjeldende kritikk av NIBIO.
//
// Konsekvensen for koden er konkret: en generator som bare dumper KR-tabeller
// PRODUSERER nettopp det oeyeblikksbildet. Den reserverer derfor plass til
// vurderingen av maaloppnaaelse per Objective -- og fyller den aldri selv, av
// samme grunn som den ikke setter confidence: vurderingen krever skjoenn.
const VURDERINGSFELT = [
'[Fylles ut av virksomheten: analysen av maaloppnaaelsen for dette Objectivet --',
'hva tallene over betyr, hva som forklarer avvikene, og hva som er laert.',
'Generatoren fyller ikke feltet; en vurdering av maaloppnaaelse krever skjoenn.',
'Uten analysen staar del III igjen som et oeyeblikksbilde av aktiviteter og',
'resultater, som er nettopp det revisjonen har paapekt. Slett klammene naar',
'feltet er fylt ut.]',
];
// DFOe-notat 2026:2 kap. 5.3.3 (s. 61): virksomhetene «skal planlegge med baade
// ettaarig og flerarig perspektiv», men «i tildelingsbrevene og aarsrapportene vi
// har sett paa omtales imidlertid i liten grad det ettaarige i et flerarig
// perspektiv». Generatoren PEKER derfor paa materialet fra tidligere sykluser --
// den regner ikke trend paa tvers av dem. Historikk-filene baerer retrospektiv-
// prosa, ikke KR-tall; en beregnet utvikling ville vaert oppdiktet.
function flerarigSeksjon(historikk) {
if (historikk.length === 0) return [];
return [
'## Flerarig perspektiv',
'',
'Virksomheten skal planlegge med baade ettaarig og flerarig perspektiv.',
'Materialet fra tidligere sykluser ligger i historikk-katalogen:',
'',
...historikk.map((h) => `- ${h.tittel} (${h.fil})`),
'',
'[Fylles ut av virksomheten: utviklingen sett over tid. Generatoren viser',
'hvilket materiale som finnes, og sammenligner ikke sykluser den ikke har',
'tall fra.]',
'',
];
}
/**
* Bygger aarsrapportens del III for en syklus lest av lesSyklus().
*
* @param {object} syklus struktur fra lesSyklus()
* @param {{naa?: string, historikk?: Array<{tittel: string, fil: string}>}} [opts]
* historikk leses av kalleren (scripts/syklus-rapport.mjs), ikke her -- modulen
* holdes fri for disk, saa formen er testbar uten et tre paa filsystemet.
* @returns {string} markdown
*/
export function arsrapportDelIII(syklus, opts = {}) {
krevSyklus(syklus, 'arsrapportDelIII');
const naa = klokke(opts);
const historikk = Array.isArray(opts.historikk) ? opts.historikk : [];
const antallKr = syklus.okrer.reduce((n, okr) => n + okr.krer.length, 0);
if (antallKr === 0) {
throw new Error(`arsrapportDelIII: syklusen ${syklus.id} inneholder ingen KR aa rapportere`);
}
const ut = [
`# Aarsrapport del III - Aarets aktiviteter og resultater (${syklus.id})`,
'',
`Generert: ${naa}`,
'',
...SKALAFORKLARING,
];
// Per Objective, fordi del III dokumenterer maaloppnaaelse mot tildelingsbrevets
// krav -- og kravene henger paa Objectives, ikke paa rapportformen.
for (const okr of syklus.okrer) {
ut.push(`## ${okr.tittel}`, '');
for (const [type, erCommitted, overskrift] of [
['committed', true, '### Committed Key Results'],
['aspirational', false, '### Aspirational Key Results'],
]) {
const krer = okr.krer.filter((kr) => kr.type === type);
if (krer.length === 0) continue;
ut.push(overskrift, '', ...krTabell(krer, erCommitted), '');
}
ut.push('### Vurdering av maaloppnaaelse', '', ...VURDERINGSFELT, '');
}
// Snittet hoerer PAA TVERS av Objectives (okr-framework.md:557) og staar derfor
// ikke i noen enkelt Objective-seksjon. Committed har bevisst ingen motpart:
// et snitt av binaere krav er ikke en stoerrelse.
const snitt = aspirationalSnitt(syklus.okrer);
if (snitt) {
ut.push(
'## Aspirational maaloppnaaelse paa tvers av Objectives',
'',
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
'paa snittet -- ikke paa ett enkelt KR.',
'',
snitt,
'',
);
}
ut.push(...flerarigSeksjon(historikk));
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}

View file

@ -8,9 +8,8 @@
//
// Former: tertial | arsrapport | etatsstyring
// EN kommandoflate med argument, ikke tre kommandoer -- samme moenster som
// /okr:oppsett full|mvp|arkiver|oppdater|vis. Bare `tertial` er implementert
// i D5; arsrapport og etatsstyring kommer i steg 11-12 og svarer inntil da
// med exit 2 og en forklarende melding, aldri stum feil.
// /okr:oppsett full|mvp|arkiver|oppdater|vis. `etatsstyring` kommer i steg 12
// og svarer inntil da med exit 2 og en forklarende melding, aldri stum feil.
//
// Exit-koder (kontrakten commands/rapport.md mapper til norsk brukertekst):
// 0 rapporten er skrevet
@ -22,13 +21,43 @@
//
// Zero npm dependencies.
import { existsSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeFrontmatter } from '../lib/frontmatter.mjs';
import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs';
import { lesSyklus } from '../lib/syklus-data.mjs';
import { tertialrapport } from '../lib/syklus-rapport.mjs';
import { arsrapportDelIII, tertialrapport } from '../lib/syklus-rapport.mjs';
// historikk/ er SOESKEN av syklus/, altsaa <syklusDir>/../../historikk. Den
// avledningen er en determinisme-risiko: regnet ut blindt ville en syklus-katalog
// utenfor et OKR-tre pekt paa en vilkaarlig katalog paa maskinen, og rapporten
// variert med hva som tilfeldigvis laa der. Derfor avledes stien KUN naar
// foreldrekatalogen faktisk heter `syklus`; ellers finnes det ingen historikk.
//
// Katalogen leses direkte -- ALDRI via index.md. Indeksen er navigasjon og kan
// peke paa filer som ikke finnes (fixturen har en slik dangling-lenke med vilje);
// et styringsdokument som lister materiale som ikke eksisterer er verre enn et
// som utelater seksjonen. readdir sorteres, fordi byte-identiske kjoeringer er
// kontrakt og lesrekkefolgen fra filsystemet ikke er garantert.
function lesHistorikk(syklusDir) {
const syklusRot = dirname(resolve(syklusDir));
if (basename(syklusRot) !== 'syklus') return [];
const dir = join(dirname(syklusRot), 'historikk');
let filer;
try {
filer = readdirSync(dir);
} catch {
return [];
}
return filer
.filter((n) => n.endsWith('.md') && n !== 'index.md')
.sort()
.map((fil) => {
const fm = parseFrontmatter(readFileSync(join(dir, fil), 'utf8'));
return { tittel: fm.get('title') ?? fil.replace(/\.md$/, ''), fil };
});
}
const FORMER = {
tertial: {
@ -37,8 +66,16 @@ const FORMER = {
beskrivelse: 'Maskingenerert tertialrapport med committed og aspirational holdt fra hverandre.',
bygg: tertialrapport,
},
// Steg 11-12. Oppfoert her, ikke skjult, saa flaten er synlig foer den virker.
arsrapport: null,
arsrapport: {
filnavn: 'rapport-arsrapport.md',
tittel: (id) => `Aarsrapport del III ${id}`,
beskrivelse: 'Maskingenerert underlag til aarsrapportens del III. Vurderingen av maaloppnaaelse er reservert til virksomheten.',
bygg: arsrapportDelIII,
// Disklesing hoerer til orkestratoren, ikke til lib/: generatorformen skal
// kunne testes uten et tre paa filsystemet.
kontekst: (syklusDir) => ({ historikk: lesHistorikk(syklusDir) }),
},
// Steg 12. Oppfoert her, ikke skjult, saa flaten er synlig foer den virker.
etatsstyring: null,
};
@ -50,7 +87,7 @@ class Bruksfeil extends Error {}
// okr-second-brain-search paa kjoepet.
// Filnavnet starter bevisst IKKE med `okr-`: ellers ville neste lesSyklus()
// plukket rapporten opp som en OKR-definisjon.
function komponer(form, syklus, naa) {
function komponer(form, syklus, naa, kontekst) {
const frontmatter = writeFrontmatter({
type: 'Status',
resource: 'local',
@ -58,7 +95,7 @@ function komponer(form, syklus, naa) {
description: form.beskrivelse,
timestamp: naa,
});
return `${frontmatter}\n${form.bygg(syklus, { naa })}`;
return `${frontmatter}\n${form.bygg(syklus, { naa, ...kontekst })}`;
}
// Atomisk skriv: temp i SAMME katalog (rename er kun atomisk innen filsystem),
@ -94,8 +131,9 @@ export function genererRapport(syklusDir, formNavn, opts = {}) {
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
const syklus = lesSyklus(syklusDir);
const kontekst = form.kontekst ? form.kontekst(syklusDir) : {};
const maal = join(syklusDir, form.filnavn);
skrivAtomisk(maal, komponer(form, syklus, naa));
skrivAtomisk(maal, komponer(form, syklus, naa, kontekst));
return { fil: maal, syklus: syklus.id, okrer: syklus.okrer.length };
}

View file

@ -13,7 +13,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lesSyklus } from '../lib/syklus-data.mjs';
import { tertialrapport } from '../lib/syklus-rapport.mjs';
import { arsrapportDelIII, tertialrapport } from '../lib/syklus-rapport.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SYKLUS_DIR = join(ROOT, 'tests/fixtures/okf-realistic/syklus/T1-2026');
@ -21,6 +21,22 @@ const NAA = '2026-05-01T10:00:00.000Z';
const rapport = () => tertialrapport(lesSyklus(SYKLUS_DIR), { naa: NAA });
// --- Invariantene gjelder HVER rapportform, ikke bare tertialrapporten ---
//
// Steg 9 hengte alle forbudene paa tertialrapporten. Da ville steg 11-12 levert
// to nye former med NULL dekning paa nettopp de vaktene som baerer doktrinen --
// og (5c), som forbyr generatoren aa utlede confidence, ville dekket 1 av 3
// former. Formene registreres her; en ny form arver hele invariant-settet ved aa
// staa i listen. Radform-vaktene (5a/5b) staar bevisst UTENFOR: de handler om
// tabellen coaching-hook.mjs teller, og aarsrapport/etatsstyring er ikke
// forpliktet til den tabellformen.
const FORMER = [
['tertial', (syklus, opts) => tertialrapport(syklus, opts)],
['arsrapport', (syklus, opts) => arsrapportDelIII(syklus, opts)],
];
const byggFor = (bygger, opts = {}) => bygger(lesSyklus(SYKLUS_DIR), { naa: NAA, ...opts });
// --- Hook-kontrakten: predikatene LESES fra hooken, aldri kopieres ---
//
// En kopiert regex beviser at testen er enig med seg selv. Ved aa hente
@ -55,11 +71,12 @@ test('(1) committed og aspirational rapporteres hver for seg', () => {
// okr-framework.md:557 -- aa aggregere de to typene til ett tall blander to
// maalestokker og gjoer resultatet tvetydig.
test('(1b) rapporten emitterer ingen felles samlet score', () => {
const md = rapport();
const offenders = md.split('\n').filter((l) => /samlet\s+score/i.test(l));
assert.deepEqual(offenders, [], `felles aggregat funnet:\n${offenders.join('\n')}`);
});
for (const [navn, bygger] of FORMER) {
test(`(1b/${navn}) rapportformen emitterer ingen felles samlet score`, () => {
const offenders = byggFor(bygger).split('\n').filter((l) => /samlet\s+score/i.test(l));
assert.deepEqual(offenders, [], `felles aggregat funnet:\n${offenders.join('\n')}`);
});
}
test('(2a) invariant 1: typen er merket i rapporteringen', () => {
const md = rapport();
@ -77,24 +94,26 @@ test('(2b) invariant 2: committed rapporteres binaert mot kravet', () => {
assert.match(rad, /\|\s*Ja\s*\|/, 'committed under target maa merkes som avvik');
});
test('(2c) invariant 3: skalaen forklares, og score konverteres ikke til prosent', () => {
const md = rapport();
assert.match(md, /skala/i, 'score-skalaen maa forklares eksplisitt');
const prosentPaaScore = md.split('\n').filter((l) => /score[^|\n]*%|%[^|\n]*score/i.test(l));
assert.deepEqual(prosentPaaScore, [], 'score skal ikke presenteres som prosent maaloppnaaelse');
});
for (const [navn, bygger] of FORMER) {
test(`(2c/${navn}) invariant 3: skalaen forklares, og score konverteres ikke til prosent`, () => {
const md = byggFor(bygger);
assert.match(md, /skala/i, 'score-skalaen maa forklares eksplisitt');
const prosentPaaScore = md.split('\n').filter((l) => /score[^|\n]*%|%[^|\n]*score/i.test(l));
assert.deepEqual(prosentPaaScore, [], 'score skal ikke presenteres som prosent maaloppnaaelse');
});
// B-2-kanten moetes her av DATA (fixturens kr1 har target == baseline).
// Uten en eksplisitt literal ville cellen blitt "undefined" eller kastet.
test('(3) udefinert score rendres som en literal, ikke som undefined eller NaN', () => {
const md = rapport();
assert.match(md, /udefinert/, 'target == baseline maa rendres som "udefinert"');
assert.doesNotMatch(md, /undefined|NaN/, 'raa JS-verdier lekket til rapporten');
});
// B-2-kanten moetes her av DATA (fixturens kr1 har target == baseline).
// Uten en eksplisitt literal ville cellen blitt "undefined" eller kastet.
test(`(3/${navn}) udefinert score rendres som en literal, ikke som undefined eller NaN`, () => {
const md = byggFor(bygger);
assert.match(md, /udefinert/, 'target == baseline maa rendres som "udefinert"');
assert.doesNotMatch(md, /undefined|NaN/, 'raa JS-verdier lekket til rapporten');
});
test('(4) determinisme: to kjoeringer med samme naa gir byte-identisk streng', () => {
assert.equal(rapport(), rapport());
});
test(`(4/${navn}) determinisme: to kjoeringer med samme naa gir byte-identisk streng`, () => {
assert.equal(byggFor(bygger), byggFor(bygger));
});
}
test('(4b) klokke-soemmen: OKR_NOW styrer tidsstempelet', (t) => {
const forrige = process.env.OKR_NOW;
@ -132,21 +151,108 @@ test('(5b) hook-kontrakt: en kanonisk etikett i Confidence-cellen goer raden tel
// okr-calculator.md:249 avviser mekanisk utledning eksplisitt ("ETT innspill
// ... ikke som en mekanisk regel"). En fremtidig sesjon som "hjelpsomt" utleder
// et trafikklys fra score ville oppfunnet en terskel doktrinen forbyr.
test('(5c) generatoren emitterer aldri en confidence-etikett selv', () => {
const { fareOrd } = hookPredikater();
const offenders = rapport().split('\n').filter((l) => fareOrd.test(l));
assert.deepEqual(
offenders,
[],
`generatoren utledet confidence mekanisk -- forbudt av okr-calculator.md:249:\n${offenders.join('\n')}`,
);
});
for (const [navn, bygger] of FORMER) {
test(`(5c/${navn}) generatoren emitterer aldri en confidence-etikett selv`, () => {
const { fareOrd } = hookPredikater();
const offenders = byggFor(bygger).split('\n').filter((l) => fareOrd.test(l));
assert.deepEqual(
offenders,
[],
`generatoren utledet confidence mekanisk -- forbudt av okr-calculator.md:249:\n${offenders.join('\n')}`,
);
});
}
test('(6) tertialrapport kaster ved tom eller ufullstendig syklus', () => {
assert.throws(() => tertialrapport({ id: 'T1-2026', okrer: [] }), /ingen OKR/i);
assert.throws(() => tertialrapport(undefined), /syklus/i);
});
// --- (8) Aarsrapport del III (steg 11) ---
//
// Ankeret er okr-offentlig-governance.md:125-133: del III «Aarets aktiviteter og
// resultater» er hovedplassen for OKR i den statlige aarsrapporten.
//
// Antipatternet generatoren maa unngaa er kildebelagt i primaerkilden:
// Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av NIBIO»,
// del av Dokument 1 (2020-2021) s. 107 -- framstillingen «synliggjoer i mindre grad
// NIBIOs analyser av maaloppnaaelsen og framstaar som et oeyeblikksbilde av NIBIOs
// aktiviteter og resultater». En generator som bare dumper tabeller PRODUSERER det
// oeyeblikksbildet. Derfor RESERVERER den plassen til vurderingen -- og fyller den
// aldri selv, siden en vurdering av maaloppnaaelse krever skjoenn.
const delIII = (opts = {}) => arsrapportDelIII(lesSyklus(SYKLUS_DIR), { naa: NAA, ...opts });
// Seksjonskropp: linjene mellom en overskrift og neste overskrift paa samme
// eller hoeyere nivaa.
function seksjonsKropp(md, overskrift) {
const linjer = md.split('\n');
const start = linjer.findIndex((l) => l.trim() === overskrift);
if (start === -1) return null;
const niva = overskrift.match(/^#+/)[0].length;
const kropp = [];
for (const linje of linjer.slice(start + 1)) {
const m = /^(#+)\s/.exec(linje);
if (m && m[1].length <= niva) break;
kropp.push(linje);
}
return kropp.join('\n').trim();
}
test('(8a) del III bygger paa aarsrapportens eget anker og en seksjon per Objective', () => {
const md = delIII();
assert.match(md, /[Aa]arets aktiviteter og resultater/, 'del III-ankeret mangler');
const objectives = md.split('\n').filter((l) => /^##\s+(?!Flerarig|Aspirational)/.test(l));
assert.equal(objectives.length, 2, `forventet 2 Objective-seksjoner, fant ${objectives.length}`);
assert.match(md, /Trafikksikkerhet og tunnelsikkerhet/);
assert.match(md, /Digitalisering av tjenester/);
});
test('(8b) hvert Objective reserverer et vurderingsfelt generatoren ikke fyller', () => {
const md = delIII();
const felt = md.split('\n').filter((l) => /^###\s+Vurdering av maaloppnaaelse\s*$/.test(l));
assert.equal(felt.length, 2, `forventet ett vurderingsfelt per Objective, fant ${felt.length}`);
const kropp = seksjonsKropp(md, '### Vurdering av maaloppnaaelse');
assert.ok(kropp, 'fant ingen kropp under vurderingsfeltet');
// Plassholderen er merket som plassholder, ikke skrevet som en vurdering.
assert.match(kropp, /^\[/, 'vurderingsfeltet maa staa som en merket plassholder');
assert.match(kropp, /\]$/, 'plassholderen maa vaere avgrenset');
assert.match(kropp, /[Ff]ylles ut av virksomheten/, 'plassholderen maa si hvem som fyller den');
// En generator som «hjelpsomt» skrev inn tall eller en konklusjon ville
// gjort seg til vurderingsinstans. Ingen tall i feltet = ingen vurdering.
assert.doesNotMatch(kropp, /\d/, `generatoren fylte vurderingsfeltet med data:\n${kropp}`);
});
test('(8c) flerarig perspektiv kommer fra historikk, og oppfinnes ikke uten data', () => {
const utenHistorikk = delIII();
assert.doesNotMatch(
utenHistorikk,
/^##\s+Flerarig perspektiv/m,
'uten historikk skal seksjonen utelates, ikke staa tom',
);
const md = delIII({
historikk: [
{ tittel: 'Retrospektiv T3-2025', fil: 'retrospektiv-T3-2025.md' },
{ tittel: 'Retrospektiv T2-2025', fil: 'retrospektiv-T2-2025.md' },
],
});
assert.match(md, /^##\s+Flerarig perspektiv/m, 'historikk skal gi et flerarig perspektiv');
const kropp = seksjonsKropp(md, '## Flerarig perspektiv');
assert.match(kropp, /Retrospektiv T3-2025/);
assert.match(kropp, /Retrospektiv T2-2025/);
// DFOe-notat 2026:2 kap. 5.3.3 (s. 61): det ettaarige omtales i liten grad i
// flerarig perspektiv. Generatoren peker paa materialet -- den sammenligner ikke
// sykluser den ikke har tall fra.
assert.doesNotMatch(kropp, /\d\.\d\d/, 'generatoren skal ikke regne trend paa tvers av sykluser');
});
test('(8d) arsrapportDelIII kaster ved tom eller ufullstendig syklus', () => {
assert.throws(() => arsrapportDelIII({ id: 'T1-2026', okrer: [] }), /ingen OKR/i);
assert.throws(() => arsrapportDelIII(undefined), /syklus/i);
});
// --- (2d) Avvik-kolonnen maa vaere retningsagnostisk ---
//
// `naa >= target` ser riktig ut for oppadgaaende maal og er FEIL for
@ -244,8 +350,8 @@ test('(7a) CLI: exit 2 ved feil aritet', async () => {
test('(7b) CLI: exit 2 ved ukjent og ved ennaa uimplementert form', async () => {
assert.equal((await kjoerCli([SYKLUS_DIR, 'tullball'])).code, 2);
// arsrapport/etatsstyring kommer i steg 11-12 og skal si det, ikke feile stumt.
for (const form of ['arsrapport', 'etatsstyring']) {
// etatsstyring kommer i steg 12 og skal si det, ikke feile stumt.
for (const form of ['etatsstyring']) {
const r = await kjoerCli([SYKLUS_DIR, form]);
assert.equal(r.code, 2, `${form} burde gi exit 2`);
assert.match(r.stderr, /ikke implementert/i, `${form} mangler forklarende melding`);
@ -280,6 +386,49 @@ test('(7e) CLI: exit 0 og fil paa disk ved gyldig input', async (t) => {
assert.match(innhold, /## Committed Key Results/);
});
// --- (7g/7h) historikk-stien er avledet, og avledningen er en determinisme-risiko ---
//
// historikk/ er SOESKEN av syklus/, altsaa <syklusDir>/../../historikk. Regnet ut
// blindt ville en syklus-katalog utenfor et OKR-tre (som testenes tmp-kopi) pekt paa
// en vilkaarlig katalog paa maskinen -- og rapporten ville variert med hva som
// tilfeldigvis laa der. Derfor avledes historikk KUN naar foreldrekatalogen faktisk
// heter `syklus`. Begge grenene testes: uten tre (7g) og med tre (7h).
test('(7g) CLI: arsrapport skrives, og uten OKR-tre utledes ingen historikk', async (t) => {
const { rmSync, readFileSync: les } = await import('node:fs');
const { rot, dir } = await midlertidigSyklus();
t.after(() => rmSync(rot, { recursive: true, force: true }));
const r = await kjoerCli([dir, 'arsrapport']);
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}: ${r.stderr ?? ''}`);
const innhold = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(innhold, /^---\ntype: Status\n/, 'skrevet fil mangler OKF-frontmatter');
assert.match(innhold, /[Aa]arets aktiviteter og resultater/);
assert.doesNotMatch(innhold, /^##\s+Flerarig perspektiv/m, 'historikk ble utledet utenfor et OKR-tre');
});
test('(7h) CLI: i et ekte OKR-tre leses historikk-katalogen, ikke index.md', 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-tre-'));
const dir = join(rot, 'syklus', 'T1-2026');
const hist = join(rot, 'historikk');
mkdirSync(dir, { recursive: true });
mkdirSync(hist);
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
writeFileSync(join(hist, 'retrospektiv-T3-2025.md'), ['---', 'type: Retrospektiv', 'title: Retrospektiv T3-2025', '---', '# Retrospektiv T3-2025', ''].join('\n'));
// index.md peker paa en fil som ikke finnes (samme dangling-kant som fixturen).
// Leser generatoren index.md i stedet for katalogen, dukker den opp i rapporten.
writeFileSync(join(hist, 'index.md'), ['# Historikk', '', '* [Retrospektiv T2-2025](retrospektiv-T2-2025.md) - finnes ikke', ''].join('\n'));
t.after(() => rmSync(rot, { recursive: true, force: true }));
assert.equal((await kjoerCli([dir, 'arsrapport'])).code, 0);
const innhold = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(innhold, /^##\s+Flerarig perspektiv/m, 'historikk-katalogen ble ikke lest');
assert.match(innhold, /Retrospektiv T3-2025/);
assert.doesNotMatch(innhold, /T2-2025/, 'en dangling index.md-lenke ble rapportert som materiale');
assert.doesNotMatch(innhold, /index\.md/, 'index.md er navigasjon, ikke kildemateriale');
});
// 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) => {