feat(okr): path-confined bundle-traversering for arkivklar

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 10:20:18 +02:00
commit 582fae3212
2 changed files with 307 additions and 0 deletions

115
lib/arkivklar.mjs Normal file
View file

@ -0,0 +1,115 @@
// arkivklar.mjs
// D7 steg 17: traverser en OKF-bundle og klassifiser innholdet mot
// bevaringsforskrifta (FOR-2025-12-19-2729). REN modul -- den leser filer den
// faar rota til, men tar ingen beslutning og skriver ingenting.
//
// Disklesing hoerer til scripts/, ikke lib/ (D6-beslutning 1). Denne modulen er
// grensetilfellet: den MAA lese for aa klassifisere, men den bestemmer ikke hvor
// rota ligger og formaterer ingen rapport. Den tar rota som argument, akkurat som
// arsrapportDelIII tar {historikk} som data.
//
// PATH-CONFINEMENT SOM LESEGRENSE (NFR med navngitt suksesskriterium):
// hvert lesemaal realpath-bekreftes under bundle-rota, via den DELTE regelen i
// lib/path-confinement.mjs -- samme kode som ingestion bruker paa skrivesiden.
// Tolags fordi de to lagene fanger ulike former; se path-confinement.mjs.
//
// MODULEN RETURNERER VURDERINGSGRUNNLAG, ALDRI EN AVGJOERELSE. Den sier hvilken
// kategori noe TROLIG faller under, aldri hva som skal skje med det. Kassasjon
// krever Nasjonalarkivets hjemmel (arkivlova § 13) etter en vurdering loven
// legger til virksomhetens dokumentasjonsplan (arkivlova § 8, arkivforskrifta
// § 12 b) -- ingen av delene kan avledes fra en filtype.
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { parseFrontmatter } from './frontmatter.mjs';
import { resolveUnderBundle, assertRealUnderBundle } from './path-confinement.mjs';
const HVEM = 'arkivklar';
// Bevaringsforskrifta § 7 (statleg sektor) navngir «tildelingsbrev, rapportar,
// etatsstyringsmoeter og evalueringa»; § 30 (kommunal sektor) navngir
// «handlingsprogram, tertialrapportering, aarsmelding».
// https://lovdata.no/dokument/SF/forskrift/2025-12-19-2729
//
// Laast arkitektur-beslutning 8: linjene er PARALLELLE, ikke alternative. En type
// kan treffe en kategori i begge, og da rapporteres begge -- modulen velger ikke
// styringslinje paa virksomhetens vegne.
const KATEGORI_BY_TYPE = {
Tildelingsbrev: { stat: 'tildelingsbrev', kommune: null },
Virksomhetsplan: { stat: null, kommune: 'handlingsprogram' },
Status: { stat: 'rapportar', kommune: 'tertialrapportering' },
Retrospektiv: { stat: 'evalueringa', kommune: 'aarsmelding' },
};
export const PARAGRAF = {
stat: '§ 7',
kommune: '§ 30',
sikring: '§ 3',
};
// Klassifiser en OKF-type mot begge styringslinjer.
//
// Sikringsparagrafen (§ 3) lukker resten: «All dokumentasjon som gjeld nye
// oppgaaver [...] skal takast vare paa inntil Nasjonalarkivet har fastsett
// reglar», og det samme for oppgaver som «av andre aarsaker ikkje er nemnde i
// forskrifta». Arkivfaglig oppsummert: det som ikke er nevnt, skal bevares.
// Ukjent type betyr derfor ALDRI «kan slettes» -- den er den mest forsiktige
// kategorien, ikke den minst.
export function klassifiser(type) {
const treff = KATEGORI_BY_TYPE[type];
if (!treff) return { stat: null, kommune: null, sikring: true };
return { stat: treff.stat, kommune: treff.kommune, sikring: false };
}
// Resolver et bundle-relativt lesemaal og bekreft at det faktisk ligger under
// rota. Kaster ved confinement-brudd; lar ENOENT boble uendret (de to feilene
// krever ulik handling hos kalleren -- se (17d)).
export function lesemaal(bundleRoot, rel) {
const resolvedBundle = path.resolve(bundleRoot);
const realBundle = realpathSync(resolvedBundle);
const maal = resolveUnderBundle(resolvedBundle, rel, HVEM);
return assertRealUnderBundle(realBundle, maal, `lesemaal ${rel}`, HVEM);
}
// index.md er navigasjon (OKF-indeksformatet), ikke et konsept. Den skal ikke
// klassifiseres -- ellers ville hver katalog produsert en falsk «Dokument»-rad.
const ER_INDEKS = (navn) => navn === 'index.md';
function lesType(absolutt) {
const { get } = parseFrontmatter(readFileSync(absolutt, 'utf8'));
return get('type') ?? null;
}
// Traverser bundlen og returner vurderingsgrunnlag per konseptfil.
//
// readdirSync sorteres eksplisitt: traverseringsrekkefoelgen er en del av
// kontrakten, siden rapporten nedstroems skal vaere byte-identisk mellom to
// kjoeringer (samme determinisme-krav som historikk-lesingen i D6).
export function traverserBundle(bundleRoot) {
const resolvedBundle = path.resolve(bundleRoot);
const realBundle = realpathSync(resolvedBundle);
const filer = [];
function gaa(relDir) {
const absDir = relDir === '' ? resolvedBundle : resolveUnderBundle(resolvedBundle, relDir, HVEM);
assertRealUnderBundle(realBundle, absDir, `katalog ${relDir || '.'}`, HVEM);
for (const entry of readdirSync(absDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const rel = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
// Symlenker foelges ikke blindt: lesemaal realpath-bekrefter hver node,
// saa en symlenket katalog som peker ut stopper traverseringen.
const abs = lesemaal(resolvedBundle, rel);
if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(abs).isDirectory())) {
gaa(rel);
continue;
}
if (!entry.name.endsWith('.md') || ER_INDEKS(entry.name)) continue;
const type = lesType(abs);
filer.push({ rel, type, ...klassifiser(type) });
}
}
gaa('');
return { rot: realBundle, filer };
}