#!/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'; import { TYPE_VOCAB } from '../lib/okf-vocab.mjs'; import { resolveBundleLink } from '../lib/okf-links.mjs'; const RECOMMENDED = ['resource', 'title', 'description', 'timestamp']; // Markdown-lenker [tekst](maal) -- brukt av --strict-ingest til aa validere at // hvert lenke-maal er en trygg, on-disk bundle-root-relativ .md (anti-RAG-poison). const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g; // En title som selv baerer en markdown-lenke er en injeksjons-vektor -> avvises. const TITLE_LINK_RE = /\[[^\]]*\]\([^)]*\)/; // Drop-zone (raa innboks-filer) + skjulte kataloger (.cache osv.) skannes ALDRI: // raa, ukonverterte og potensielt fiendtlige filer er ikke konsepter. function isWalkableDir(name) { return !name.startsWith('.') && name !== 'innboks'; } // 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()) { if (isWalkableDir(e.name)) 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; } // strictIngest (default AV): paa skrivestien handheves det lukkede vokabularet // + lenke-allow-listen. Lese-siden (default) forblir tolerant (exit 0/1 kun paa // manglende type) -- denne porten er aktiv KUN ved ingestion (--strict-ingest). export function checkBundle(root, { strictIngest = false } = {}) { const concepts = walkConcepts(root); const missingType = []; const warnings = []; const strictErrors = []; for (const f of concepts) { const raw = readFileSync(f, 'utf8'); const { get } = parseFrontmatter(raw); const rel = relative(root, f); const type = get('type'); if (!type) { missingType.push(rel); continue; } if (strictIngest) { // 1. type maa vaere i det lukkede ingestion-vokabularet. if (!TYPE_VOCAB.includes(type)) { strictErrors.push(`${rel}: type «${type}» utenfor ingestion-vokabular`); } // 2. en title som baerer en markdown-lenke er en injeksjons-vektor. const title = get('title'); if (title && TITLE_LINK_RE.test(title)) { strictErrors.push(`${rel}: lenke-baerende title «${title}»`); } // 3. hver markdown-lenke maa resolvere til en trygg, on-disk bundle-fil. for (const m of raw.matchAll(MD_LINK_RE)) { const target = m[1]; const resolved = resolveBundleLink(target, root); if (!resolved) strictErrors.push(`${rel}: utrygg lenke ${target}`); else if (!existsSync(resolved)) strictErrors.push(`${rel}: dangling lenke ${target}`); } } for (const field of RECOMMENDED) { if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`); } } return { scanned: concepts.length, missingType, warnings, strictErrors, strictIngest, okfVersion: rootOkfVersion(root), }; } // --- CLI --- const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { const args = process.argv.slice(2); const strictIngest = args.includes('--strict-ingest'); const root = args.find((a) => !a.startsWith('--')); if (!root) { process.stderr.write('Bruk: node okf-check.mjs [--strict-ingest]\n'); process.exit(2); } if (!existsSync(root)) { process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`); process.exit(2); } const r = checkBundle(root, { strictIngest }); const out = []; out.push(`OKF-sjekk: ${root}${strictIngest ? ' (strict-ingest)' : ''}`); out.push(` Konsept-filer skannet: ${r.scanned}`); out.push(` ${r.missingType.length} filer uten type:`); for (const f of r.missingType) out.push(` - ${f}`); if (strictIngest) { out.push(` ${r.strictErrors.length} strict-ingest-feil:`); for (const e of r.strictErrors) out.push(` x ${e}`); } 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}`); const failed = r.missingType.length > 0 || (strictIngest && r.strictErrors.length > 0); let verdict; if (!failed) verdict = 'OK: gyldig OKF-bundle'; else if (r.missingType.length > 0) verdict = `FEIL: ${r.missingType.length} fil(er) mangler type:`; else verdict = `FEIL: ${r.strictErrors.length} strict-ingest-feil`; out.push(verdict); process.stdout.write(`${out.join('\n')}\n`); process.exit(failed ? 1 : 0); }