// 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 }; }