feat(okr): tertialrapport-generator med separert committed/aspirational
This commit is contained in:
parent
1592a73267
commit
50933d839d
2 changed files with 304 additions and 0 deletions
156
lib/syklus-rapport.mjs
Normal file
156
lib/syklus-rapport.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// syklus-rapport.mjs
|
||||
// D5 steg 9: tertialrapport fra en lest syklus. REN modul (ingen shebang, ingen
|
||||
// isMain-CLI) -- orkestratoren bor i scripts/syklus-rapport.mjs.
|
||||
// Zero npm dependencies.
|
||||
//
|
||||
// SEEMEN, og hvorfor den ligger der den ligger (beslutning B-3):
|
||||
// generatoren eier ARITMETIKK, TABELLSTRUKTUR og FORMATERINGSINVARIANTER.
|
||||
// Den eier ALDRI confidence. okr-framework.md:563 gjoer confidence-tabellen til
|
||||
// eneste sannhetskilde og forbyr andre filer aa definere egne terskler;
|
||||
// okr-calculator.md:249 avviser mekanisk utledning ordrett ("bruk gapet ... som
|
||||
// ETT innspill til confidence-vurderingen, ikke som en mekanisk regel"). En
|
||||
// generator som satte et trafikklys fra score ville derfor oppfunnet en terskel
|
||||
// doktrinen forbyr. Confidence-kolonnen staar tom by design og fylles av
|
||||
// /okr:sporing, der skjoennet hoerer hjemme.
|
||||
//
|
||||
// Committed-kolonnen "Avvik" er IKKE en terskel: den er sammenligningen
|
||||
// naa >= target, som okr-offentlig-governance.md:150-152 krever ("et lovkrav er
|
||||
// naadd eller ikke; score 0.9 paa et lovpaalagt krav er et avvik").
|
||||
//
|
||||
// Rapporten holdes ASCII-ren, som resten av den maskingenererte flaten.
|
||||
|
||||
import { beregnScore } from './syklus-data.mjs';
|
||||
|
||||
// Literal for score som ikke er definert som ratio (target == baseline, B-2).
|
||||
// Et tomt felt ville lest som manglende data; "0" ville loeyet om at KR-en
|
||||
// ikke har beveget seg.
|
||||
const UDEFINERT = 'udefinert';
|
||||
|
||||
const TABELLHODE = [
|
||||
'| KR | Baseline | Target | Naa | Score | Avvik | Confidence |',
|
||||
'|----|----------|--------|-----|-------|-------|------------|',
|
||||
];
|
||||
|
||||
const formatScore = (score) => (score === undefined ? UDEFINERT : score.toFixed(2));
|
||||
|
||||
// Governance-invariant 2: committed maales binaert mot kravet, ikke paa score.
|
||||
// Aspirational har ingen Avvik-kolonneverdi -- 0.7 er forventet, ikke svikt.
|
||||
const formatAvvik = (kr, erCommitted) => {
|
||||
if (!erCommitted) return '-';
|
||||
return kr.naa >= kr.target ? 'Nei' : 'Ja';
|
||||
};
|
||||
|
||||
function krRad(kr, erCommitted) {
|
||||
const score = formatScore(beregnScore(kr));
|
||||
// Siste celle (Confidence) staar bevisst tom -- se seem-notatet over.
|
||||
return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(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) {
|
||||
const linjer = [];
|
||||
let antallKr = 0;
|
||||
for (const okr of okrer) {
|
||||
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('');
|
||||
}
|
||||
return { linjer, antallKr };
|
||||
}
|
||||
|
||||
// Aspirational vurderes paa SNITTET paa tvers av alle aspirational-OKR, ikke
|
||||
// paa ett enkelt KR (okr-framework.md:557). Snittet er derfor kanonisk for
|
||||
// denne typen -- og finnes bevisst ikke som et felles tall paa tvers av typene.
|
||||
// KR uten definert score kan ikke inngaa; antallet oppgis i stedet for aa
|
||||
// forsvinne stille.
|
||||
function aspirationalSnitt(okrer) {
|
||||
const scorer = [];
|
||||
let udefinerte = 0;
|
||||
for (const okr of okrer) {
|
||||
for (const kr of okr.krer) {
|
||||
if (kr.type !== 'aspirational') continue;
|
||||
const s = beregnScore(kr);
|
||||
if (s === undefined) udefinerte += 1;
|
||||
else scorer.push(s);
|
||||
}
|
||||
}
|
||||
if (scorer.length === 0) return null;
|
||||
const snitt = scorer.reduce((a, b) => a + b, 0) / scorer.length;
|
||||
const hale = udefinerte > 0 ? ` (${udefinerte} KR uten definert score er holdt utenfor)` : '';
|
||||
return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bygger tertialrapporten for en syklus lest av lesSyklus().
|
||||
*
|
||||
* @param {object} syklus struktur fra lesSyklus()
|
||||
* @param {{naa?: string}} [opts] naa overstyrer klokka; ellers OKR_NOW, ellers veggklokke
|
||||
* (klokke-soem etter moenster fra scripts/compose-org-profile.mjs:61)
|
||||
* @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());
|
||||
}
|
||||
|
||||
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
|
||||
const committed = seksjon(syklus.okrer, 'committed', true);
|
||||
const aspirational = seksjon(syklus.okrer, 'aspirational', false);
|
||||
|
||||
if (committed.antallKr + aspirational.antallKr === 0) {
|
||||
throw new Error(`tertialrapport: syklusen ${syklus.id} inneholder ingen KR aa rapportere`);
|
||||
}
|
||||
|
||||
const ut = [
|
||||
`# Tertialrapport ${syklus.id}`,
|
||||
'',
|
||||
`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.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (committed.antallKr > 0) {
|
||||
ut.push(
|
||||
'## Committed Key Results',
|
||||
'',
|
||||
// Governance-invariant 1 + 2, uttalt der mottakeren leser tallene.
|
||||
'Committed KR maales binaert mot kravet: kravet er naadd eller ikke. En score',
|
||||
'under 1.0 er et avvik som skal forklares, ikke et godt resultat.',
|
||||
'',
|
||||
...committed.linjer,
|
||||
);
|
||||
}
|
||||
|
||||
if (aspirational.antallKr > 0) {
|
||||
ut.push(
|
||||
'## Aspirational Key Results',
|
||||
'',
|
||||
// Governance-invariant 1: typen merkes, slik at 0.7 ikke leses som svikt.
|
||||
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
|
||||
'paa snittet paa tvers av alle aspirational-OKR -- ikke paa ett enkelt KR.',
|
||||
'',
|
||||
...aspirational.linjer,
|
||||
);
|
||||
const snitt = aspirationalSnitt(syklus.okrer);
|
||||
if (snitt) ut.push(snitt, '');
|
||||
}
|
||||
|
||||
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
|
||||
}
|
||||
148
tests/syklus-rapport.test.mjs
Normal file
148
tests/syklus-rapport.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// syklus-rapport.test.mjs
|
||||
// D5 steg 9: tertialrapport-generatoren. Tester tre bindende krav:
|
||||
// (1) committed og aspirational rapporteres HVER FOR SEG (okr-framework.md:557)
|
||||
// (2) de tre formateringsinvariantene (okr-offentlig-governance.md:148-156)
|
||||
// (3) hook-kontrakten mot hooks/scripts/coaching-hook.mjs:86-92
|
||||
//
|
||||
// Offline, zero-dep, node:test. Test-kilden holdes ASCII-ren (bash 3.2 set -u).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SYKLUS_DIR = join(ROOT, 'tests/fixtures/okf-realistic/syklus/T1-2026');
|
||||
const NAA = '2026-05-01T10:00:00.000Z';
|
||||
|
||||
const rapport = () => tertialrapport(lesSyklus(SYKLUS_DIR), { naa: NAA });
|
||||
|
||||
// --- Hook-kontrakten: predikatene LESES fra hooken, aldri kopieres ---
|
||||
//
|
||||
// En kopiert regex beviser at testen er enig med seg selv. Ved aa hente
|
||||
// literalene ut av coaching-hook.mjs' egen kilde blir en fremtidig endring i
|
||||
// hooken en ROED suite her, i stedet for en stille inkompatibilitet.
|
||||
// Hooken er i forbidden_paths for dette steget -- forbudt aa ENDRE, ikke aa LESE.
|
||||
function hookPredikater() {
|
||||
const src = readFileSync(join(ROOT, 'hooks/scripts/coaching-hook.mjs'), 'utf8');
|
||||
const funnet = [...src.matchAll(/(\/(?:[^/\\\n]|\\.)+\/[gimsuy]*)\.test\(line\)/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
assert.equal(
|
||||
funnet.length,
|
||||
2,
|
||||
`parser-sanity: forventet 2 rad-predikater i coaching-hook.mjs, fant ${funnet.length}`,
|
||||
);
|
||||
const kompiler = (lit) => {
|
||||
const siste = lit.lastIndexOf('/');
|
||||
return new RegExp(lit.slice(1, siste), lit.slice(siste + 1));
|
||||
};
|
||||
return { radForm: kompiler(funnet[0]), fareOrd: kompiler(funnet[1]) };
|
||||
}
|
||||
|
||||
const erKrRad = (linje) => /^\s*\|/.test(linje) && !/^\s*\|[\s|:-]+\|\s*$/.test(linje)
|
||||
&& !/\|\s*KR\s*\|/.test(linje);
|
||||
|
||||
test('(1) committed og aspirational rapporteres hver for seg', () => {
|
||||
const md = rapport();
|
||||
assert.match(md, /^##\s+Committed/m, 'mangler committed-seksjon');
|
||||
assert.match(md, /^##\s+Aspirational/m, 'mangler aspirational-seksjon');
|
||||
});
|
||||
|
||||
// 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')}`);
|
||||
});
|
||||
|
||||
test('(2a) invariant 1: typen er merket i rapporteringen', () => {
|
||||
const md = rapport();
|
||||
assert.match(md, /committed/i);
|
||||
assert.match(md, /aspirational/i);
|
||||
});
|
||||
|
||||
test('(2b) invariant 2: committed rapporteres binaert mot kravet', () => {
|
||||
const md = rapport();
|
||||
// Avvik-kolonnen finnes, og tunnel-KR2 (17 av 20) er et avvik selv om
|
||||
// scoren er 0.63 -- et krav er naadd eller ikke.
|
||||
assert.match(md, /\|\s*Avvik\s*\|/, 'mangler Avvik-kolonne for committed');
|
||||
const rad = md.split('\n').find((l) => l.includes('oppgradert sikkerhetsutrustning'));
|
||||
assert.ok(rad, 'fant ikke committed-KR-raden');
|
||||
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');
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
test('(4) determinisme: to kjoeringer med samme naa gir byte-identisk streng', () => {
|
||||
assert.equal(rapport(), rapport());
|
||||
});
|
||||
|
||||
test('(4b) klokke-soemmen: OKR_NOW styrer tidsstempelet', (t) => {
|
||||
const forrige = process.env.OKR_NOW;
|
||||
t.after(() => {
|
||||
if (forrige === undefined) delete process.env.OKR_NOW;
|
||||
else process.env.OKR_NOW = forrige;
|
||||
});
|
||||
process.env.OKR_NOW = '2026-06-15T08:00:00.000Z';
|
||||
const md = tertialrapport(lesSyklus(SYKLUS_DIR));
|
||||
assert.match(md, /2026-06-15T08:00:00\.000Z/);
|
||||
});
|
||||
|
||||
// --- (5) Hook-kontrakten, tredelt ---
|
||||
|
||||
test('(5a) hook-kontrakt: hver KR-rad matcher hookens radform', () => {
|
||||
const { radForm } = hookPredikater();
|
||||
const rader = rapport().split('\n').filter(erKrRad);
|
||||
assert.ok(rader.length >= 4, `parser-sanity: fant ${rader.length} KR-rader`);
|
||||
const avvikende = rader.filter((l) => !radForm.test(l));
|
||||
assert.deepEqual(avvikende, [], `rader hooken ikke kan telle:\n${avvikende.join('\n')}`);
|
||||
});
|
||||
|
||||
test('(5b) hook-kontrakt: en kanonisk etikett i Confidence-cellen goer raden tellbar', () => {
|
||||
const { radForm, fareOrd } = hookPredikater();
|
||||
const rad = rapport().split('\n').filter(erKrRad)[0];
|
||||
// Confidence er siste kolonne og staar tom by design -- fyll den slik
|
||||
// /okr:sporing ville gjort det.
|
||||
const fylt = rad.replace(/\|\s*\|\s*$/, '| At Risk |');
|
||||
assert.notEqual(fylt, rad, 'fant ingen tom Confidence-celle aa fylle');
|
||||
assert.ok(radForm.test(fylt) && fareOrd.test(fylt), 'hooken ville ikke telt den fylte raden');
|
||||
});
|
||||
|
||||
// LOAD-BEARING. Generatoren eier aritmetikk, aldri confidence:
|
||||
// okr-framework.md:563 gjoer confidence-tabellen til eneste sannhetskilde, og
|
||||
// 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')}`,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue