okr/scripts/okf-check.mjs

206 lines
9.3 KiB
JavaScript

#!/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 to markoerer (`okf_version` = upstream OKF-versjon, spec §3;
// `okf_layout` = emitterens egen layout-revisjon, spec §12) 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';
import { unquote } from './okf-index.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 = /<a\b[^>]*\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 to markoerer fra index.md. `okf_version` = upstream OKF-versjon
// (spec §3), `okf_layout` = emitterens egen layout-revisjon (spec §12, valgfri).
// Fravaerende markoer -> null. Ren ekko: verdiene valideres ikke (spec §3 er
// ikke haandhevende paa form ennaa).
//
// TO PLASSERINGER, bevisst (D8 steg 20). Beslutning 6 flytter `okf_version` til
// frontmatter mens `okf_layout` blir i broedteksten, men bundles paa disk
// migrerer ikke samtidig -- en bundle skrevet av en eldre okf-index baerer
// fortsatt begge i broedteksten. Lesingen proever derfor frontmatter FOERST og
// faller tilbake til broedteksten. Presedensen er en REGEL her, ikke en
// bivirkning av at frontmatter tilfeldigvis staar oeverst i fila.
//
// De to lagene har ULIKE tolkningsregler, og det er hele grunnen til at de er
// skilt:
// - frontmatter ER YAML -> parseFrontmatter haandterer sitering OG trailing
// ` # kommentar` (lib/frontmatter.mjs:39). En raa `^key:`-scan lot
// kommentaren lekke inn i verdien.
// - broedteksten er IKKE YAML -> der er unquote() alene riktig regel, delt
// import fra okf-index (produsent-siden) og ikke en kopi. Uten den ga samme
// fil to lesninger: produsenten tolket `okf_version: "0.2"` som 0.2,
// checkeren ekkoet «"0.2"». Upstreams eneste kanoniske eksempel med verdi
// (SPEC.md:773) er sitert, saa det er formen en spec-tro bundle har paa disk.
function rootMarkers(root) {
const idx = join(root, 'index.md');
if (!existsSync(idx)) return { okfVersion: null, okfLayout: null };
const raw = readFileSync(idx, 'utf8');
const { raw: fmRaw, get: fmGet } = parseFrontmatter(raw);
// Broedteksten = alt UNDER frontmatter-blokken, slik at de to lagene kan feile
// hver for seg (mutasjon M1 roedner (20a)/(20d) NETTOPP fordi fallbacken ikke
// ser frontmatter-linjene).
//
// AERLIG OM DEKNINGEN: uttrekket er ikke produksjonsobserverbart i dag. Naar
// noekkelen finnes i frontmatter i en form parseFrontmatter i det hele tatt
// returnerer, vinner fm-laget foer fallbacken kjoeres -- saa `body = raw`
// roedner ingen test (maalt, ikke antatt). Det beholdes likevel: fjernes det,
// ligger en latent defekt og venter paa at lib/frontmatter.mjs's list-key-
// kontrakt (:13-15) blir sann. I dag returnerer get() paa en list-key foerste
// LIST-ELEMENT, ikke null, fordi `\s*` i :29-regexen spiser linjeskiftet.
// Fikses det (patch-lane), begynner fallbacken aa kjoere for list-keys -- og
// uten dette uttrekket ville den plukket «- a» ut av frontmatter.
const body = fmRaw === null ? raw : raw.slice(raw.indexOf('\n---', 3) + 4);
const pick = (key) => {
const fromFm = fmGet(key);
if (fromFm !== null) return unquote(fromFm);
const m = body.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
return m ? unquote(m[1].trim()) : null;
};
return { okfVersion: pick('okf_version'), okfLayout: pick('okf_layout') };
}
// 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,
...rootMarkers(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 <bundle-rot> [--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(` okf_layout: ${r.okfLayout || 'MANGLER (valgfri markoer)'}`);
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);
}