#!/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, resolve } 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']; // Lenke-deteksjon for --strict-ingest: hvert lenke-maal maa vaere en trygg, // on-disk bundle-root-relativ .md (anti-RAG-poison). B3 (A1): ALLE fire // standard lenkeformer fanges -- inline, referanse-definisjon, autolink og // raa HTML-anker -- ikke bare inline (de tre siste lakk foer A1). const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g; const REF_DEF_RE = /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(\S+)/gm; const AUTOLINK_RE = /<([a-z][a-z0-9+.-]*:[^<>\s]*)>/gi; const HTML_HREF_RE = /]*\bhref\s*=\s*["']?([^"'\s>]+)/gi; const LINK_FORMS = [MD_LINK_RE, REF_DEF_RE, AUTOLINK_RE, HTML_HREF_RE]; // 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). // files (B2, A1): orkestratoren scoper strict til KUN kjoeringens skrevne filer // (writeConcepts-returens {concepts, pointers}) -- ALDRI hele roten, som ville // felt legitimt haandkuratert innhold (lovlig out-of-vocab/eksterne lenker paa // lese-siden). Uten files: full-root-walk som foer (CLI/lese-siden). export function checkBundle(root, { strictIngest = false, files } = {}) { const concepts = Array.isArray(files) ? files.map((f) => resolve(root, f)) : 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. hvert lenke-maal (alle fire lenkeformer, B3) maa resolvere til en // trygg, on-disk bundle-fil. for (const re of LINK_FORMS) { for (const m of raw.matchAll(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); }