okr/lib/syklus-data.mjs

142 lines
5.3 KiB
JavaScript

// syklus-data.mjs
// D5 steg 8: leser en OKR-syklus fra disk og beregner KR-score kanonisk.
// REN modul -- ingen shebang, ingen isMain-CLI (lib/-siden av splitten;
// orkestratoren bor i scripts/syklus-rapport.mjs). Zero npm dependencies.
//
// Datamodellen er beslutning B-1: flate `krN_`-noekler i okr-*.md sin frontmatter.
// Noekkelen `krN_type` kolliderer ikke med OKF-noekkelen `type`, fordi
// lib/frontmatter.mjs:29 ankrer paa `^\s*type:` -- verifisert kjoert mot data,
// ikke bare lest (tests/syklus-data.test.mjs A4).
//
// Beslutning B-2: score BEREGNES, lagres aldri. En lagret score er en andre
// sannhetskilde som kan drifte fra tallene den ble utledet av.
//
// Disiplin: modulen KASTER heller enn aa falle tilbake paa en stille default
// (moenster: lib/innboks-frontmatter.mjs:64-72). En manglende target som stille
// ble 0 ville produsert en score som ser gyldig ut, og et styringsdokument som
// lyver med to desimalers presisjon er verre enn et som feiler.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import { parseFrontmatter } from './frontmatter.mjs';
// De fem feltene KR-datakontrakten krever. Alle maa vaere til stede per KR n --
// et delvis utfylt KR er en feil, ikke et KR med hull.
const KR_FELT = ['navn', 'baseline', 'target', 'naa', 'type'];
const KR_TALLFELT = ['baseline', 'target', 'naa'];
const KR_TYPER = ['committed', 'aspirational'];
function somTall(verdi, felt, hvor) {
const n = Number(verdi);
if (verdi === null || verdi === undefined || String(verdi).trim() === '' || Number.isNaN(n)) {
throw new Error(`${hvor}: ${felt} maa vaere et rent tall uten enhet (fikk: ${verdi})`);
}
return n;
}
/**
* Kanonisk KR-score: (naa - baseline) / (target - baseline), kappet til [0, 1.0].
*
* Tre kanter, alle fra okr-framework.md:319 / okr-calculator.md:7-24:
* - target === baseline -> undefined (IKKE 0). Forholdet er udefinert, ikke null
* fremgang; forskjellen er den mellom et KR som feilet og et som ikke kan
* scores som ratio.
* - nedadgaaende maal trenger ingen saertilfelle: teller og nevner blir begge
* negative, saa fortegnet gaar opp av seg selv.
* - binaert KR (baseline 0, target 1) faller ut av samme formel som 0 eller 1.
*
* @returns {number|undefined} score i [0, 1.0], eller undefined naar udefinert.
*/
export function beregnScore(kr) {
if (!kr || typeof kr !== 'object') {
throw new Error(`beregnScore: forventet et KR-objekt (fikk: ${kr})`);
}
const baseline = somTall(kr.baseline, 'baseline', 'beregnScore');
const target = somTall(kr.target, 'target', 'beregnScore');
const naa = somTall(kr.naa, 'naa', 'beregnScore');
if (target === baseline) return undefined;
const raa = (naa - baseline) / (target - baseline);
return Math.min(1, Math.max(0, raa));
}
// Samler `krN_*`-noeklene i frontmatteren til en sortert KR-liste. Nummereringen
// leses fra dataene (ikke antatt 1..n), saa et hull i nummerserien blir synlig
// som et manglende KR i stedet for aa forskyve alle KR-ene etter det.
function lesKrer(fm, hvor) {
const numre = new Set();
for (const linje of (fm.raw ?? '').split('\n')) {
const m = /^\s*kr(\d+)_[a-z]+\s*:/.exec(linje);
if (m) numre.add(Number(m[1]));
}
const krer = [];
for (const n of [...numre].sort((a, b) => a - b)) {
const raa = {};
for (const felt of KR_FELT) {
const verdi = fm.get(`kr${n}_${felt}`);
if (verdi === null) {
throw new Error(
`${hvor}: kr${n} mangler ${felt}. KR-datakontrakten krever alle fem feltene ` +
`(${KR_FELT.join(', ')}) -- se /okr:skriv.`,
);
}
raa[felt] = verdi;
}
const type = String(raa.type).toLowerCase();
if (!KR_TYPER.includes(type)) {
throw new Error(
`${hvor}: kr${n}_type maa vaere ${KR_TYPER.join(' eller ')} (fikk: ${raa.type})`,
);
}
const kr = { n, navn: raa.navn, type };
for (const felt of KR_TALLFELT) {
kr[felt] = somTall(raa[felt], `kr${n}_${felt}`, hvor);
}
krer.push({ n, navn: kr.navn, baseline: kr.baseline, target: kr.target, naa: kr.naa, type });
}
return krer;
}
/**
* Leser alle `okr-*.md` i en syklus-katalog til en normalisert struktur.
*
* Filrekkefolgen er sortert filnavn og er DEL AV KONTRAKTEN: rapportgeneratoren
* arver den, saa to kjoeringer over samme katalog gir samme dokument.
*
* @param {string} syklusDir katalog som inneholder okr-*.md
* @returns {{id: string, okrer: Array<{fil: string, tittel: string, krer: Array}>}}
*/
export function lesSyklus(syklusDir) {
let stat;
try {
stat = statSync(syklusDir);
} catch {
throw new Error(`lesSyklus: syklus-katalogen finnes ikke: ${syklusDir}`);
}
if (!stat.isDirectory()) {
throw new Error(`lesSyklus: syklus-katalogen finnes ikke som katalog: ${syklusDir}`);
}
const filer = readdirSync(syklusDir)
.filter((n) => n.startsWith('okr-') && n.endsWith('.md'))
.sort();
if (filer.length === 0) {
throw new Error(`lesSyklus: fant ingen okr-*.md i ${syklusDir}`);
}
const okrer = filer.map((fil) => {
const fm = parseFrontmatter(readFileSync(join(syklusDir, fil), 'utf8'));
return {
fil,
tittel: fm.get('title') ?? fil.replace(/\.md$/, ''),
krer: lesKrer(fm, fil),
};
});
return { id: basename(syklusDir), okrer };
}