diff --git a/scripts/okf-check.mjs b/scripts/okf-check.mjs new file mode 100644 index 0000000..57a33e0 --- /dev/null +++ b/scripts/okf-check.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// okf-check.mjs +// Validerer en OKF-bundle-rot. Kontrakt (de-facto OKF: kun `type` paakrevd): +// - Hver konsept-fil (.md unntatt index.md) MAA ha `type:` i frontmatter. +// - >= 1 fil uten type -> exit 1 + teller + navngir filene. +// - 0 filer uten type -> exit 0. +// Anbefalte felt (resource/title/description/timestamp) rapporteres som ADVARSEL, +// ikke feil. Rotens `okf_version` ekkoes for menneskelig sammenligning mot +// gjeldende standard (ingen auto-fetch — hooks/scripts er no-network; SC7 myket). +// +// Kjoeres PER ROT (prosjekt `.claude/okr/` + home `~/.claude/okr/org/`). +// Zero npm dependencies (node:-builtins). + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; + +const RECOMMENDED = ['resource', 'title', 'description', 'timestamp']; + +// Alle konsept-filer (.md unntatt index.md) under root, rekursivt. +function walkConcepts(root) { + const out = []; + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') out.push(p); + } + }; + walk(root); + return out; +} + +// Les rotens okf_version (markdown-tekst i index.md, ikke frontmatter). null hvis fravaerende. +function rootOkfVersion(root) { + const idx = join(root, 'index.md'); + if (!existsSync(idx)) return null; + const m = readFileSync(idx, 'utf8').match(/^okf_version:\s*(.+)$/m); + return m ? m[1].trim() : null; +} + +export function checkBundle(root) { + const concepts = walkConcepts(root); + const missingType = []; + const warnings = []; + for (const f of concepts) { + const { get } = parseFrontmatter(readFileSync(f, 'utf8')); + const rel = relative(root, f); + if (!get('type')) { + missingType.push(rel); + continue; + } + for (const field of RECOMMENDED) { + if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`); + } + } + return { + scanned: concepts.length, + missingType, + warnings, + okfVersion: rootOkfVersion(root), + }; +} + +// --- CLI --- +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const root = process.argv[2]; + if (!root) { + process.stderr.write('Bruk: node okf-check.mjs \n'); + process.exit(2); + } + if (!existsSync(root)) { + process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`); + process.exit(2); + } + const r = checkBundle(root); + const out = []; + out.push(`OKF-sjekk: ${root}`); + out.push(` Konsept-filer skannet: ${r.scanned}`); + out.push(` ${r.missingType.length} filer uten type:`); + for (const f of r.missingType) out.push(` - ${f}`); + out.push(` okf_version: ${r.okfVersion || 'MANGLER (rot-index uten okf_version)'}`); + out.push(` Advarsler (anbefalte felt): ${r.warnings.length}`); + for (const w of r.warnings) out.push(` ! ${w}`); + out.push(r.missingType.length === 0 ? 'OK: gyldig OKF-bundle' : `FEIL: ${r.missingType.length} fil(er) mangler type:`); + process.stdout.write(`${out.join('\n')}\n`); + process.exit(r.missingType.length === 0 ? 0 : 1); +} diff --git a/scripts/okf-index.mjs b/scripts/okf-index.mjs new file mode 100644 index 0000000..81f5a61 --- /dev/null +++ b/scripts/okf-index.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// okf-index.mjs +// Genererer OKF-kompatible `index.md` per nivaa i en bundle-rot (prosjekt +// `.claude/okr/` eller home `~/.claude/okr/org/`). Verbatim OKF-«Documents/kb +// Layout»-index-form: +// # Overskrift +// +// okf_version: (KUN rot-index) +// +// * [title](relativ.md) - description +// Ingen frontmatter paa index.md (OKF-reservert). Konsept-filers `title`/ +// `description` leses via lib/frontmatter.mjs. Underkataloger faar en peker til +// sin egen index.md. Kjoeres PER ROT (de to bundlene har ulik livssyklus). +// +// Idempotens / vedlikehold (NFR): en eksisterende index.md sin `# overskrift`, +// rotens `okf_version`-verdi, og menneske-skrevne beskrivelser for underkatalog- +// pekere bevares; konsept-entries regenereres alltid fra frontmatter (autoritativ +// kilde). Skriving er atomisk (temp + renameSync), jf. write-org-profile.mjs. +// +// Zero npm dependencies (node:-builtins). + +import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; + +export const OKF_VERSION = 'kb-layout-2026-06'; + +// "strategisk-kontekst" -> "Strategisk kontekst" +function titleFromName(name) { + const spaced = name.replace(/[-_]+/g, ' ').trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +// Parse en eksisterende index.md for bevaring: overskrift, okf_version, og +// beskrivelser pr. lenke (for underkatalog-pekere). Kaster aldri. +function parseExistingIndex(path) { + const result = { heading: null, okfVersion: null, descByLink: {} }; + if (!existsSync(path)) return result; + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (result.heading === null && line.startsWith('# ')) { + result.heading = line.slice(2).trim(); + } + const ver = line.match(/^okf_version:\s*(.+)$/); + if (ver) result.okfVersion = ver[1].trim(); + const entry = line.match(/^\*\s*\[([^\]]*)\]\(([^)]+)\)(?:\s*-\s*(.*))?$/); + if (entry) result.descByLink[entry[2]] = { title: entry[1], desc: (entry[3] || '').trim() }; + } + return result; +} + +// Bygg en enkelt entry-linje paa OKF-form. Tom beskrivelse -> dropp ` - d`. +function entryLine(title, link, desc) { + return desc ? `* [${title}](${link}) - ${desc}` : `* [${title}](${link})`; +} + +// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version. +function writeIndexFor(dir, isRoot, okfVersion) { + const existing = parseExistingIndex(join(dir, 'index.md')); + const dirents = readdirSync(dir, { withFileTypes: true }); + const subdirs = dirents.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + const concepts = dirents + .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') + .map((e) => e.name) + .sort(); + + const heading = existing.heading + || (isRoot ? 'OKF second brain' : titleFromName(basename(dir))); + + const lines = [`# ${heading}`, '']; + if (isRoot) { + lines.push(`okf_version: ${existing.okfVersion || okfVersion}`, ''); + } + + for (const sd of subdirs) { + const link = `${sd}/index.md`; + const prev = existing.descByLink[link]; + lines.push(entryLine(prev?.title || titleFromName(sd), link, prev?.desc || 'Underkatalog.')); + } + for (const c of concepts) { + const { get } = parseFrontmatter(readFileSync(join(dir, c), 'utf8')); + const title = get('title') || titleFromName(basename(c, '.md')); + lines.push(entryLine(title, c, get('description') || '')); + } + + const content = `${lines.join('\n')}\n`; + const tmp = join(dir, `.index.md.${process.pid}.tmp`); + writeFileSync(tmp, content); + renameSync(tmp, join(dir, 'index.md')); +} + +// Generer index.md for rot + alle underkataloger, rekursivt. +export function generateIndexes(root, opts = {}) { + const okfVersion = opts.okfVersion || OKF_VERSION; + const walk = (dir, isRoot) => { + writeIndexFor(dir, isRoot, okfVersion); + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) walk(join(dir, e.name), false); + } + }; + if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`); + walk(root, true); +} + +// --- CLI --- +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const root = process.argv[2]; + if (!root) { + process.stderr.write('Bruk: node okf-index.mjs [--okf-version ]\n'); + process.exit(2); + } + const vi = process.argv.indexOf('--okf-version'); + const okfVersion = vi !== -1 ? process.argv[vi + 1] : undefined; + generateIndexes(root, { okfVersion }); + process.stdout.write(`OKF-index generert for ${root}\n`); +} diff --git a/tests/okf-check.test.mjs b/tests/okf-check.test.mjs new file mode 100644 index 0000000..3b3564b --- /dev/null +++ b/tests/okf-check.test.mjs @@ -0,0 +1,137 @@ +// okf-check.test.mjs +// Tester OKF-mekanismen (Step 6): okf-index genererer index.md per nivaa i ekte +// OKF-form, og okf-check validerer at hver konsept-fil baerer `type:`. +// okf-index kjoeres mot en TEMP-KOPI av okf-realistic (cpSync) saa de committede +// fixturene (lest read-only av okf-retrieval.test.mjs, m/ en bevisst dangling-link) +// forblir uroert. okf-check kjoeres som subprosess for aa fange exit-koden (kontrakt). +// Zero npm deps. Plassert i tests/ (samme moenster som org-profile-write.test.mjs). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateIndexes } from '../scripts/okf-index.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const CHECK = join(ROOT, 'scripts', 'okf-check.mjs'); +const REALISTIC = join(ROOT, 'tests', 'fixtures', 'okf-realistic'); + +function tmpRoot() { + return mkdtempSync(join(tmpdir(), 'okf-')); +} + +// Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da). +function runCheck(root) { + try { + const stdout = execFileSync('node', [CHECK, root], { encoding: 'utf8' }); + return { status: 0, stdout }; + } catch (e) { + return { status: e.status ?? 1, stdout: `${e.stdout || ''}${e.stderr || ''}` }; + } +} + +// Alle kataloger under root (inkl. root selv), rekursivt. +function allDirs(root) { + const out = [root]; + for (const e of readdirSync(root, { withFileTypes: true })) { + if (e.isDirectory()) out.push(...allDirs(join(root, e.name))); + } + return out; +} + +// --- okf-index --- + +test('okf-index: genererer index.md per nivaa (hver katalog) i temp-kopi', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + for (const d of allDirs(dir)) { + assert.ok(existsSync(join(d, 'index.md')), `index.md skal finnes i ${d}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: entry-linjer matcher OKF-form `* [t](l) - d`, uten frontmatter', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const sk = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.ok(!sk.startsWith('---'), 'index.md skal IKKE ha frontmatter'); + const entries = sk.split('\n').filter((l) => l.startsWith('* ')); + assert.ok(entries.length >= 3, 'strategisk-kontekst skal liste sine 3 konsept-filer'); + for (const l of entries) { + assert.match(l, /^\* \[[^\]]+\]\([^)]+\) - .+$/, `OKF-form: ${l}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: rot-index baerer okf_version, undernivaa gjoer ikke', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const root = readFileSync(join(dir, 'index.md'), 'utf8'); + assert.match(root, /^okf_version:\s*\S+/m, 'rot-index skal ha okf_version'); + const sub = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.ok(!/^okf_version:/m.test(sub), 'undernivaa skal IKKE ha okf_version'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: konsept-tittel/beskrivelse hentes fra frontmatter', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const sk = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.match( + sk, + /\* \[Tildelingsbrev 2026\]\(tildelingsbrev-2026\.md\) - Departementets/, + 'entry skal bruke frontmatter-title + -description + lenke til konsept-fil', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- okf-check --- + +test('okf-check: gyldig bundle (alle konsept har type:) -> exit 0, «0 filer uten type:»', () => { + const { status, stdout } = runCheck(REALISTIC); + assert.equal(status, 0, 'gyldig bundle skal gi exit 0'); + assert.match(stdout, /0 filer uten type:/, 'skal rapportere null manglende type'); +}); + +test('okf-check: konsept-fil uten type: -> exit != 0 + teller > 0 + navngir filen', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + writeFileSync( + join(dir, 'strategisk-kontekst', 'mangler-type.md'), + '---\ntitle: Uten type\ndescription: En konsept-fil uten paakrevd type.\n---\n# Uten type\n', + ); + const { status, stdout } = runCheck(dir); + assert.notEqual(status, 0, 'type-loes fil skal gi exit != 0'); + assert.match(stdout, /[1-9]\d* filer uten type:/, 'teller skal vaere > 0'); + assert.match(stdout, /mangler-type\.md/, 'skal navngi den feilende filen'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check: rapporterer okf_version fra rot-index', () => { + const { stdout } = runCheck(REALISTIC); + assert.match(stdout, /okf_version:\s*kb-layout-2026-06/, 'skal ekko okf_version for menneskelig sammenligning'); +});