From 3b45be70be799cf0209a5d2fa2011164fa57c863 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 30 Jun 2026 13:55:34 +0200 Subject: [PATCH] feat(okr): okf-check++ strictIngest (vokab+lenker) + skip innboks/dot (SC konformitet) [skip-docs] Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y --- scripts/okf-check.mjs | 76 ++++++++++++++++++++---- tests/okf-check.test.mjs | 125 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 14 deletions(-) diff --git a/scripts/okf-check.mjs b/scripts/okf-check.mjs index 57a33e0..877c720 100644 --- a/scripts/okf-check.mjs +++ b/scripts/okf-check.mjs @@ -15,17 +15,34 @@ 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()) walk(p); - else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') out.push(p); + 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); @@ -40,17 +57,41 @@ function rootOkfVersion(root) { return m ? m[1].trim() : null; } -export function checkBundle(root) { +// 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 { get } = parseFrontmatter(readFileSync(f, 'utf8')); + const raw = readFileSync(f, 'utf8'); + const { get } = parseFrontmatter(raw); const rel = relative(root, f); - if (!get('type')) { + 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}»`); } @@ -59,6 +100,8 @@ export function checkBundle(root) { scanned: concepts.length, missingType, warnings, + strictErrors, + strictIngest, okfVersion: rootOkfVersion(root), }; } @@ -67,25 +110,36 @@ export function checkBundle(root) { const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - const root = process.argv[2]; + 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 \n'); + 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); + const r = checkBundle(root, { strictIngest }); const out = []; - out.push(`OKF-sjekk: ${root}`); + 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}`); - out.push(r.missingType.length === 0 ? 'OK: gyldig OKF-bundle' : `FEIL: ${r.missingType.length} fil(er) mangler type:`); + 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(r.missingType.length === 0 ? 0 : 1); + process.exit(failed ? 1 : 0); } diff --git a/tests/okf-check.test.mjs b/tests/okf-check.test.mjs index 3b3564b..daee49a 100644 --- a/tests/okf-check.test.mjs +++ b/tests/okf-check.test.mjs @@ -10,7 +10,7 @@ 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, + mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, mkdirSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; @@ -26,15 +26,36 @@ function tmpRoot() { } // Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da). -function runCheck(root) { +// extra: ekstra CLI-flagg (f.eks. '--strict-ingest'). +function runCheck(root, ...extra) { try { - const stdout = execFileSync('node', [CHECK, root], { encoding: 'utf8' }); + const stdout = execFileSync('node', [CHECK, root, ...extra], { encoding: 'utf8' }); return { status: 0, stdout }; } catch (e) { return { status: e.status ?? 1, stdout: `${e.stdout || ''}${e.stderr || ''}` }; } } +// Bygg en ren ingestion-bundle: kanoniske vokab-typer + en trygg, on-disk relasjon. +function buildCleanIngest(dir) { + mkdirSync(join(dir, 'strategisk-kontekst'), { recursive: true }); + mkdirSync(join(dir, 'dokumenter'), { recursive: true }); + writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n'); + writeFileSync( + join(dir, 'strategisk-kontekst', 'tildelingsbrev.md'), + '---\ntype: Tildelingsbrev\nresource: urn:okr:tb\ntitle: Tildelingsbrev 2026\n' + + "description: Styringssignaler.\ntimestamp: '2026-01-15T09:00:00+00:00'\n---\n" + + '# Tildelingsbrev 2026\n\nHoveddokument.\n', + ); + writeFileSync( + join(dir, 'dokumenter', 'notat.md'), + '---\ntype: Notat\nresource: urn:okr:notat\ntitle: Internt notat\n' + + "description: Et notat.\ntimestamp: '2026-02-01T09:00:00+00:00'\n---\n" + + '# Internt notat\n\n## Relaterte dokumenter\n\n' + + '- [Tildelingsbrev 2026](/strategisk-kontekst/tildelingsbrev.md)\n', + ); +} + // Alle kataloger under root (inkl. root selv), rekursivt. function allDirs(root) { const out = [root]; @@ -135,3 +156,101 @@ 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'); }); + +// --- okf-check --strict-ingest (Step 8): lukket vokab + lenke-allow-liste --- + +test('okf-check --strict-ingest: ren ingestion-bundle (vokab-type + trygg on-disk relasjon) -> exit 0', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 0, `ren bundle skal gi exit 0:\n${stdout}`); + assert.match(stdout, /0 strict-ingest-feil/, 'skal rapportere null strict-feil'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: type utenfor vokabular -> exit 1 + navngir filen', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'rar.md'), + '---\ntype: Tilfeldig\ntitle: Rar\ndescription: x\n---\n# Rar\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'out-of-vocab type skal gi exit 1'); + assert.match(stdout, /rar\.md: type .* utenfor ingestion-vokabular/, 'skal navngi feilen'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: usikker lenke (escape ut av bundle) -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'ond.md'), + '---\ntype: Notat\ntitle: Ond\ndescription: x\n---\n# Ond\n\n- [exfil](../../../etc/passwd)\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'utrygg lenke skal gi exit 1'); + assert.match(stdout, /utrygg lenke/, 'skal rapportere utrygg lenke'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: dangling bundle-lenke (trygg form, mangler on-disk) -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'henger.md'), + '---\ntype: Notat\ntitle: Henger\ndescription: x\n---\n# Henger\n\n' + + '- [Mangler](/strategisk-kontekst/finnes-ikke.md)\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'dangling lenke skal gi exit 1'); + assert.match(stdout, /dangling lenke/, 'skal rapportere dangling lenke'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: lenke-baerende title -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'tittel.md'), + '---\ntype: Notat\ntitle: "[Klikk her](http://evil.example)"\ndescription: x\n---\n# T\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'lenke-baerende title skal gi exit 1'); + assert.match(stdout, /lenke-baerende title/, 'skal rapportere lenke-baerende title'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- walk-eksklusjon (Step 8): drop-zone + dot-kataloger skannes aldri --- + +test('okf-check: raa filer i innboks/ og .cache/ ignoreres av walk (ingen falsk exit 1)', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + mkdirSync(join(dir, 'innboks'), { recursive: true }); + mkdirSync(join(dir, '.cache'), { recursive: true }); + // Raa filer UTEN type -- ville gitt exit 1 hvis walk ikke ekskluderte dem. + writeFileSync(join(dir, 'innboks', 'raa.md'), '# Raa innboks-fil uten frontmatter\n'); + writeFileSync(join(dir, '.cache', 'c.md'), '# Cache uten type\n'); + const { status, stdout } = runCheck(dir); + assert.equal(status, 0, `drop-zone/dot-filer skal ignoreres:\n${stdout}`); + assert.match(stdout, /0 filer uten type:/, 'walk skal ikke telle drop-zone-filer'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});