feat(okr): arkivklar-rapport med bevaringskategorier som vurderingsgrunnlag
This commit is contained in:
parent
582fae3212
commit
a9aab508d2
3 changed files with 255 additions and 2 deletions
|
|
@ -113,3 +113,88 @@ export function traverserBundle(bundleRoot) {
|
|||
gaa('');
|
||||
return { rot: realBundle, filer };
|
||||
}
|
||||
|
||||
// Klokke-soem: OKR_NOW (ISO-8601) overstyrer veggklokka, saa to kjoeringer over
|
||||
// samme tre gir byte-identisk rapport. Speiler lib/syklus-rapport.mjs:194.
|
||||
const klokke = (opts) => opts?.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
|
||||
// Rendrer vurderingsgrunnlaget. Formuleringen er en INVARIANT, ikke stil:
|
||||
//
|
||||
// - Hver kategori presenteres som «faller trolig under ... § N», med
|
||||
// paragrafhenvisning, og vurderingen legges eksplisitt til virksomhetens
|
||||
// dokumentasjonsplan (arkivlova § 8, arkivforskrifta § 12 b).
|
||||
// - Rapporten sier hva som boer VURDERES OVERFOERT til sak-arkivet. Den sier
|
||||
// aldri hva som skal slettes eller kasseres -- kassasjon krever
|
||||
// Nasjonalarkivets hjemmel (arkivlova § 13). Det finnes INGEN slettemodell i
|
||||
// denne pluginen, og ingen skal innfoeres: retensjonsbehovet er dekket av
|
||||
// /okr:oppsett arkiver og /okr:export.
|
||||
// - Arkivforskrifta § 1 bokstav c gjoer vurderingen betinget av om
|
||||
// dokumentasjonen allerede forvaltes som arkiv i et ANNET system. Det kan
|
||||
// verktoeyet ikke se, og rapporten navngir derfor tilstanden som ukjent i
|
||||
// stedet for aa anta den bort.
|
||||
export function rapport(grunnlag, opts) {
|
||||
const { rot, filer } = grunnlag;
|
||||
const l = [];
|
||||
|
||||
l.push('# Arkivklar — vurderingsgrunnlag');
|
||||
l.push('');
|
||||
l.push(`Bundle: \`${rot}\``);
|
||||
l.push(`Generert: ${klokke(opts)}`);
|
||||
l.push(`Konseptfiler gjennomgaatt: ${filer.length}`);
|
||||
l.push('');
|
||||
l.push('> **Dette er et vurderingsgrunnlag, ikke en avgjoerelse.** Rapporten viser hvilke');
|
||||
l.push('> kategorier i bevaringsforskrifta (FOR-2025-12-19-2729) innholdet trolig faller');
|
||||
l.push('> under, og hva som boer vurderes overfoert til virksomhetens sak-/arkivsystem.');
|
||||
l.push('> Selve vurderingen hoerer til virksomhetens dokumentasjonsplan, jf. arkivlova § 8');
|
||||
l.push('> og arkivforskrifta § 12 b. Kassasjon krever hjemmel fra Nasjonalarkivet etter');
|
||||
l.push('> arkivlova § 13, og avgjoeres ikke her.');
|
||||
l.push('');
|
||||
l.push('## Ukjent forutsetning');
|
||||
l.push('');
|
||||
l.push('Arkivforskrifta § 1 bokstav c gjoer vurderingen betinget av om denne');
|
||||
l.push('dokumentasjonen **allerede forvaltes som arkiv i et annet system**. Verktoeyet');
|
||||
l.push('leser kun dette treet og kan ikke se sak-/arkivsystemet. Tilstanden er derfor');
|
||||
l.push('**ukjent**, og maa avklares av virksomheten foer grunnlaget brukes.');
|
||||
l.push('');
|
||||
|
||||
const seksjoner = [
|
||||
['stat', `Statleg styringslinje — bevaringsforskrifta ${PARAGRAF.stat}`, (f) => f.stat !== null],
|
||||
['kommune', `Kommunal styringslinje — bevaringsforskrifta ${PARAGRAF.kommune}`, (f) => f.kommune !== null],
|
||||
];
|
||||
|
||||
for (const [noekkel, tittel, filter] of seksjoner) {
|
||||
const treff = filer.filter(filter);
|
||||
l.push(`## ${tittel}`);
|
||||
l.push('');
|
||||
if (treff.length === 0) {
|
||||
l.push('Ingen filer i treet traff en navngitt kategori i denne paragrafen.');
|
||||
} else {
|
||||
l.push('| Fil | OKF-type | Navngitt kategori | Vurdering |');
|
||||
l.push('|-----|----------|-------------------|-----------|');
|
||||
for (const f of treff) {
|
||||
l.push(`| \`${f.rel}\` | ${f.type ?? '(ingen type)'} | ${f[noekkel]} | Boer vurderes overfoert til sak-arkivet |`);
|
||||
}
|
||||
}
|
||||
l.push('');
|
||||
}
|
||||
|
||||
const sikring = filer.filter((f) => f.sikring);
|
||||
l.push(`## Ikke navngitt — sikringsparagrafen ${PARAGRAF.sikring}`);
|
||||
l.push('');
|
||||
l.push('Bevaringsforskrifta § 3 fastsetter at dokumentasjon som ikke er nevnt i');
|
||||
l.push('forskrifta skal takast vare paa inntil Nasjonalarkivet har fastsett reglar.');
|
||||
l.push('At en fil staar her betyr derfor at den skal **bevares**, ikke det motsatte.');
|
||||
l.push('');
|
||||
if (sikring.length === 0) {
|
||||
l.push('Ingen filer falt utenfor de navngitte kategoriene.');
|
||||
} else {
|
||||
l.push('| Fil | OKF-type | Vurdering |');
|
||||
l.push('|-----|----------|-----------|');
|
||||
for (const f of sikring) {
|
||||
l.push(`| \`${f.rel}\` | ${f.type ?? '(ingen type)'} | Bevares inntil Nasjonalarkivet har fastsett reglar |`);
|
||||
}
|
||||
}
|
||||
l.push('');
|
||||
|
||||
return `${l.join('\n')}\n`;
|
||||
}
|
||||
|
|
|
|||
68
scripts/arkivklar.mjs
Normal file
68
scripts/arkivklar.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env node
|
||||
// arkivklar.mjs
|
||||
// D7 steg 18: orkestrator for /okr:arkivklar. Leser en OKF-bundle og skriver et
|
||||
// VURDERINGSGRUNNLAG mot bevaringsforskrifta -- aldri en avgjoerelse.
|
||||
//
|
||||
// Arbeidsdelingen foelger D6-beslutning 1: lib/arkivklar.mjs eier traversering,
|
||||
// klassifisering og rendring; dette skriptet eier disk-siden (hvor rota ligger,
|
||||
// hvor rapporten havner) og CLI-kontrakten.
|
||||
//
|
||||
// STDOUT ER DEFAULT, fil er opt-in. Kommandoen (/okr:arkivklar) deklarerer
|
||||
// verken Write eller Edit, og en verify-kjoering mot tests/fixtures/ skal ikke
|
||||
// kunne skitne til fixturen. Oppgis en rapport-fil, skrives den ATOMISK
|
||||
// (temp i samme katalog + renameSync) og ALDRI inn i bundlen selv.
|
||||
//
|
||||
// INGEN SLETTEMODELL FINNES HER, og ingen skal innfoeres. Retensjonsbehovet er
|
||||
// dekket av /okr:oppsett arkiver (arkivering av syklus) og /okr:export (uttrekk).
|
||||
// Kassasjon krever Nasjonalarkivets hjemmel etter arkivlova § 13 og er ikke en
|
||||
// operasjon dette verktoeyet kan ha.
|
||||
//
|
||||
// Exit: 0 = rapport produsert · 1 = uventet feil (inkl. confinement-brudd)
|
||||
// 2 = bruksfeil (manglende/ukjent sti)
|
||||
|
||||
import { writeFileSync, renameSync, existsSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { traverserBundle, rapport } from '../lib/arkivklar.mjs';
|
||||
|
||||
// Atomisk: temp-fil i SAMME katalog, deretter renameSync over maalet.
|
||||
// Speiler write-org-profile.mjs:34-40 / innboks-write.mjs.
|
||||
function writeAtomic(target, data) {
|
||||
const dir = path.dirname(target);
|
||||
const tmp = path.join(dir, `${path.basename(target)}.${process.pid}.tmp`);
|
||||
writeFileSync(tmp, data);
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
export function byggRapport(bundleRoot, opts) {
|
||||
return rapport(traverserBundle(bundleRoot), opts);
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
const isMain = process.argv[1]
|
||||
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
const [bundleRoot, rapportFil] = process.argv.slice(2);
|
||||
if (!bundleRoot) {
|
||||
process.stderr.write('Bruk: node arkivklar.mjs <bundle-rot> [rapport-fil]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(bundleRoot) || !statSync(bundleRoot).isDirectory()) {
|
||||
process.stderr.write(`Finnes ikke (eller er ikke en katalog): ${bundleRoot}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const ut = byggRapport(bundleRoot);
|
||||
if (rapportFil) {
|
||||
writeAtomic(path.resolve(rapportFil), ut);
|
||||
process.stdout.write(`Arkivklar: vurderingsgrunnlag skrevet til ${rapportFil}\n`);
|
||||
} else {
|
||||
process.stdout.write(ut);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(`arkivklar: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,14 +18,17 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { lesemaal, klassifiser, traverserBundle } from '../lib/arkivklar.mjs';
|
||||
import { lesemaal, klassifiser, traverserBundle, rapport } from '../lib/arkivklar.mjs';
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/okf-realistic', import.meta.url));
|
||||
const SCRIPT = fileURLToPath(new URL('../scripts/arkivklar.mjs', import.meta.url));
|
||||
const NAA = '2026-08-02T10:00:00.000Z';
|
||||
|
||||
function withTmp(fn) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arkivklar-'));
|
||||
|
|
@ -190,3 +193,100 @@ test('traverserBundle: symlenket underkatalog som peker ut avvises', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== (18) rapporten tar ikke stilling ====================
|
||||
|
||||
// FORBUDTE ORD skannes i den GENERERTE RAPPORTEN, aldri i kildefila.
|
||||
// scripts/arkivklar.mjs og lib/arkivklar.mjs baerer selv ordene «slett»/«kassasjon»
|
||||
// i forklarende prosa («ingen slettemodell finnes») -- en vakt som skannet kilden
|
||||
// ville felt sin egen forbydende tekst og maattet mykes opp til den ble verdiloes.
|
||||
// Det er S48-defektklassen; skann utfallet, ikke intensjonen.
|
||||
const IMPERATIV = /\b(slett|slette|slettes|kasser|kassere|kasseres|fjern|fjernes)\b/i;
|
||||
|
||||
test('(18a) rapporten inneholder ingen kassasjons- eller slette-imperativ', () => {
|
||||
const linjer = rapport(traverserBundle(FIXTURE), { naa: NAA }).split('\n');
|
||||
const treff = linjer.filter((l) => IMPERATIV.test(l)).map((l) => l.trim());
|
||||
assert.deepEqual(
|
||||
treff,
|
||||
[],
|
||||
`rapporten skal aldri instruere kassasjon/sletting:\n${treff.join('\n')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('(18b) parser-sanity: IMPERATIV-vakten fanger faktisk et slikt imperativ', () => {
|
||||
// Uten denne ville (18a) bestaatt trivielt paa en regex som ikke matcher noe.
|
||||
assert.ok(IMPERATIV.test('Disse filene kan slettes etter 2027.'), 'vakten skal treffe et ekte imperativ');
|
||||
assert.ok(!IMPERATIV.test('Vurderingen hoerer til virksomhetens dokumentasjonsplan.'), 'legitim prosa skal gaa klar');
|
||||
});
|
||||
|
||||
test('(18c) rapporten baerer paragrafhenvisning og vurderingsformulering', () => {
|
||||
const ut = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
||||
for (const forventet of ['§ 7', '§ 30', '§ 3', 'arkivlova § 8', 'arkivforskrifta § 12 b', 'vurder']) {
|
||||
assert.ok(ut.toLowerCase().includes(forventet.toLowerCase()), `rapporten mangler «${forventet}»`);
|
||||
}
|
||||
});
|
||||
|
||||
test('(18d) rapporten navngir den ukjente tilstanden etter arkivforskrifta § 1 bokstav c', () => {
|
||||
// Vurderingen er betinget av om dokumentasjonen ALLEREDE forvaltes som arkiv i
|
||||
// et annet system -- en tilstand verktoeyet ikke kan se. Rapporten maa si det,
|
||||
// ikke stilltiende anta det ene eller det andre.
|
||||
const ut = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
||||
assert.ok(/§ 1 bokstav c/.test(ut), 'rapporten skal navngi arkivforskrifta § 1 bokstav c');
|
||||
assert.ok(/ukjent/i.test(ut), 'rapporten skal navngi tilstanden som ukjent');
|
||||
});
|
||||
|
||||
test('(18e) rapporten er deterministisk (to kjoeringer byte-identisk)', () => {
|
||||
const a = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
||||
const b = rapport(traverserBundle(FIXTURE), { naa: NAA });
|
||||
assert.equal(a, b, 'samme input + samme klokke skal gi byte-identisk rapport');
|
||||
});
|
||||
|
||||
// ==================== (18f-h) CLI-form og exit-koder ====================
|
||||
|
||||
function kjor(args, env = {}) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, OKR_NOW: NAA, ...env },
|
||||
});
|
||||
return { code: 0, stdout };
|
||||
} catch (e) {
|
||||
return { code: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
test('(18f) CLI paa fixturen gir exit 0 og skriver rapporten til stdout', () => {
|
||||
const r = kjor([FIXTURE]);
|
||||
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}`);
|
||||
assert.ok(/§ 7/.test(r.stdout), 'stdout skal baere rapporten');
|
||||
assert.deepEqual(
|
||||
r.stdout.split('\n').filter((l) => IMPERATIV.test(l)),
|
||||
[],
|
||||
'CLI-utskriften skal heller ikke baere slette-imperativ',
|
||||
);
|
||||
});
|
||||
|
||||
test('(18g) CLI uten argument gir exit 2 (bruksfeil)', () => {
|
||||
assert.equal(kjor([]).code, 2, 'manglende bundle-rot skal gi exit 2');
|
||||
});
|
||||
|
||||
test('(18h) CLI paa ikke-eksisterende rot gir exit 2', () => {
|
||||
withTmp((tmp) => {
|
||||
assert.equal(kjor([join(tmp, 'finnes-ikke')]).code, 2, 'ukjent rot skal gi exit 2');
|
||||
});
|
||||
});
|
||||
|
||||
test('(18i) CLI skriver rapport atomisk til oppgitt fil, uten aa roere bundlen', () => {
|
||||
withTmp((tmp) => {
|
||||
const ut = join(tmp, 'arkivklar-rapport.md');
|
||||
const r = kjor([FIXTURE, ut]);
|
||||
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}`);
|
||||
const skrevet = readFileSync(ut, 'utf8');
|
||||
assert.ok(/§ 7/.test(skrevet), 'fila skal baere rapporten');
|
||||
assert.deepEqual(
|
||||
readdirSync(tmp).filter((n) => n.endsWith('.tmp')),
|
||||
[],
|
||||
'ingen .tmp skal lekke (atomisk temp + rename)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue