feat(okr): innboks-ingestion orkestrator end-to-end, idempotent by construction (SC1-6) [skip-docs]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
188b534a3b
commit
7643304177
2 changed files with 486 additions and 0 deletions
240
scripts/innboks-ingest.mjs
Normal file
240
scripts/innboks-ingest.mjs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env node
|
||||
// innboks-ingest.mjs
|
||||
// Step 12 (A2): pipeline-orkestrator for innboks-ingestion. Kjoerer drop-zonen
|
||||
// (.claude/okr/innboks/) gjennom den deterministiske kjeden og persisterer
|
||||
// konsepter i prosjekt-bundlen. INGEN LLM i v1 (--enrich = v1.1-soem, ingen flagg).
|
||||
//
|
||||
// Faserekkefoelge (A2-redesign av plan.md Step 12 -- begrunnelse under):
|
||||
// 1. discover: flat walk av drop-zonen (kun filer, dotfiler skippes), sortert
|
||||
// (deterministisk rekkefoelge = stabil disambiguering). Ukjent extension ->
|
||||
// skip + norsk notice (via convert), aldri feil.
|
||||
// 2. PER DOKUMENT (staging + gate, laast A0): convert -> splitConcepts ->
|
||||
// projectFrontmatter (timestamp = original-mtime, IKKE veggklokke) ->
|
||||
// kryss-kilde destRel-disambiguering (B5: kolliderende destRel fra annen
|
||||
// kilde faar --<sourceSlug>-suffiks, stabilt gitt sortert rekkefoelge) ->
|
||||
// writeConcepts (delt claimed-register) -> per-dokument-gate
|
||||
// checkBundle(root, {strictIngest: true, files: <dokumentets skrevne filer>})
|
||||
// -- strict scopes til KUN kjoeringens skrevne filer, ALDRI hele roten
|
||||
// (B2: kuratert innhold med lovlige eksterne lenker/out-of-vocab-type kan
|
||||
// ikke felle ingestion). Gate-feil -> discard-on-fail: KUN det dokumentets
|
||||
// skrivinger rulles tilbake (Map-Reduce-isolasjon -- ett fiendtlig dok
|
||||
// forgifter ikke andre).
|
||||
// 3. resolveRelations over ALLE overlevende dokumenters konsepter, DERETTER
|
||||
// relasjons-omskriv av konsepter som fikk relasjoner.
|
||||
// A2-REDESIGN (avvik fra plan-kjedens "relations foer write"): relasjoner
|
||||
// per dokument alene kan aldri emittere kryss-dokument-lenker (fixture-SC
|
||||
// dok-a -> dok-b), og relasjoner FOER gaten ville latt et discardet
|
||||
// dokument etterlate dangling relasjonslenker i overlevende dokumenter
|
||||
// (isolasjonsbrudd). Gate foerst, relasjoner blant overlevende etterpaa:
|
||||
// hvert relasjonsmaal er da garantert paa disk.
|
||||
// 4. generateIndexes(root) over det overlevende settet.
|
||||
// 5. Belte+seler: scoped strict-sjekk av ALLE overlevende skrevne filer --
|
||||
// feil her er et internt invariant-brudd (skal aldri skje), ikke dok-feil.
|
||||
//
|
||||
// Idempotens by construction: fil-settet er en funksjon av drop-zonens innhold
|
||||
// + mtime alene (run1 vs run2 byte-identisk, testet via sha256-manifest).
|
||||
// Project-root-only: writeConcepts avviser home-org-rota; ingen cross-repo.
|
||||
//
|
||||
// Bruk: node scripts/innboks-ingest.mjs <innboks-katalog> <bundle-rot>
|
||||
// Exit: 0 = alle dokumenter OK (skip av ukjent filtype er OK), 1 = minst ett
|
||||
// dokument feilet/discardet (eller internt invariant-brudd), 2 = bruksfeil.
|
||||
|
||||
import { readdirSync, statSync, unlinkSync, rmdirSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { convert } from '../lib/convert/index.mjs';
|
||||
import { splitConcepts } from '../lib/innboks-split.mjs';
|
||||
import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs';
|
||||
import { resolveRelations } from '../lib/innboks-relations.mjs';
|
||||
import { writeConcepts } from '../lib/innboks-write.mjs';
|
||||
import { generateIndexes } from './okf-index.mjs';
|
||||
import { checkBundle } from './okf-check.mjs';
|
||||
|
||||
// Kebab-slug -- speiler innboks-split.mjs:13-21 (frosset modul, eksporterer
|
||||
// ikke slugify; semantikken MAA vaere identisk med konsept-slugging).
|
||||
function slugify(text) {
|
||||
const s = String(text)
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return s || 'konsept';
|
||||
}
|
||||
|
||||
// Flat, sortert discover av drop-zonen: kun regulaere filer, dotfiler skippes.
|
||||
// (Underkataloger i drop-zonen er udefinert i v1 -- de ignoreres stille.)
|
||||
function discover(inboxDir) {
|
||||
return readdirSync(inboxDir, { withFileTypes: true })
|
||||
.filter((e) => !e.name.startsWith('.') && !e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Unik sourceSlug per fil: basename uten extension; kollisjon (dok.docx vs
|
||||
// dok.pdf) -> extension-suffiks; deretter teller. Deterministisk i sortert orden.
|
||||
function assignSourceSlug(name, taken) {
|
||||
const ext = path.extname(name);
|
||||
const base = slugify(path.basename(name, ext));
|
||||
const candidates = [base, `${base}-${slugify(ext)}`];
|
||||
for (const c of candidates) {
|
||||
if (!taken.has(c)) {
|
||||
taken.add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
for (let i = 2; ; i += 1) {
|
||||
const c = `${candidates[1]}-${i}`;
|
||||
if (!taken.has(c)) {
|
||||
taken.add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B5: destRel-disambiguering PAA TVERS av kilder. claimed = det delte registeret
|
||||
// (absolutt maal -> sourceSlug) som writeConcepts ogsaa haandhever. Kolliderende
|
||||
// destRel fra en ANNEN kilde faar --<sourceSlug>-suffiks (stabilt: avhenger kun
|
||||
// av input-settet + sortert rekkefoelge, aldri veggklokke).
|
||||
function disambiguate(concepts, resolvedBundle, claimed) {
|
||||
return concepts.map((c) => {
|
||||
let destRel = c.destRel;
|
||||
const owner = claimed.get(path.resolve(resolvedBundle, destRel));
|
||||
if (owner !== undefined && owner !== c.sourceSlug) {
|
||||
const dir = path.dirname(destRel);
|
||||
const base = path.basename(destRel, '.md');
|
||||
destRel = `${dir}/${base}--${c.sourceSlug}.md`;
|
||||
for (let i = 2; claimed.has(path.resolve(resolvedBundle, destRel)); i += 1) {
|
||||
destRel = `${dir}/${base}--${c.sourceSlug}-${i}.md`;
|
||||
}
|
||||
}
|
||||
return destRel === c.destRel ? c : { ...c, destRel };
|
||||
});
|
||||
}
|
||||
|
||||
// Discard-on-fail: fjern dokumentets skrevne filer + toemte foreldre-kataloger
|
||||
// (opp til bundle-rota), og slipp claimed-registreringene.
|
||||
function discard(written, resolvedBundle, claimed) {
|
||||
for (const file of written) {
|
||||
if (existsSync(file)) unlinkSync(file);
|
||||
claimed.delete(file);
|
||||
let dir = path.dirname(file);
|
||||
while (dir !== resolvedBundle && dir.startsWith(resolvedBundle + path.sep)) {
|
||||
try {
|
||||
rmdirSync(dir); // kaster hvis ikke tom -> ferdig aa rydde
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Orkestrer hele ingest-kjoeringen. Returnerer
|
||||
// { ingested: [sourceSlug], skipped: [filnavn], failed: [{source, reason}], written: [absolutt sti] }
|
||||
export async function ingestInbox(inboxDir, bundleRoot, { onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {}) {
|
||||
const resolvedBundle = path.resolve(bundleRoot);
|
||||
const claimed = new Map(); // absolutt maal -> sourceSlug (delt med writeConcepts, B5)
|
||||
const takenSlugs = new Set();
|
||||
const ingested = [];
|
||||
const skipped = [];
|
||||
const failed = [];
|
||||
const survivors = []; // { source, concepts (disambiguert), written }
|
||||
|
||||
for (const name of discover(inboxDir)) {
|
||||
const filePath = path.join(inboxDir, name);
|
||||
const sourceSlug = assignSourceSlug(name, takenSlugs);
|
||||
let written = null;
|
||||
try {
|
||||
const markdown = await convert(filePath, { onNotice });
|
||||
if (markdown === null) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
const mtime = statSync(filePath).mtime;
|
||||
const sourcePath = path.relative(resolvedBundle, path.resolve(filePath)).split(path.sep).join('/');
|
||||
const raw = splitConcepts(markdown, { sourceSlug });
|
||||
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: mtime }));
|
||||
const concepts = disambiguate(projected, resolvedBundle, claimed);
|
||||
|
||||
const result = writeConcepts(concepts, {
|
||||
bundleRoot: resolvedBundle,
|
||||
originals: [{ sourceSlug, path: filePath }],
|
||||
claimed,
|
||||
});
|
||||
written = [...result.concepts, ...result.pointers];
|
||||
|
||||
// Per-dokument-gate, scoped til KUN dette dokumentets skrevne filer (B2).
|
||||
const gate = checkBundle(resolvedBundle, { strictIngest: true, files: written });
|
||||
const errors = [...gate.missingType.map((f) => `mangler type: ${f}`), ...gate.strictErrors];
|
||||
if (errors.length > 0) {
|
||||
discard(written, resolvedBundle, claimed);
|
||||
failed.push({ source: name, reason: errors.join('; ') });
|
||||
continue;
|
||||
}
|
||||
|
||||
survivors.push({ source: name, sourceSlug, concepts, written });
|
||||
ingested.push(sourceSlug);
|
||||
} catch (e) {
|
||||
// Skrive-/konverteringsfeil (kuratert-kollisjon, symlink, manglende dep):
|
||||
// discard det som maatte vaere skrevet, kjoeringen fortsetter (isolasjon).
|
||||
if (written) discard(written, resolvedBundle, claimed);
|
||||
failed.push({ source: name, reason: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Fase 3: relasjoner blant OVERLEVENDE dokumenters konsepter (kryss-dokument;
|
||||
// hvert maal er garantert paa disk). Omskriv kun konsepter som fikk relasjoner.
|
||||
const allConcepts = survivors.flatMap((s) => s.concepts);
|
||||
const related = resolveRelations(allConcepts);
|
||||
const withRelations = related.filter((c) => c.relations.length > 0);
|
||||
if (withRelations.length > 0) {
|
||||
writeConcepts(withRelations, { bundleRoot: resolvedBundle, claimed });
|
||||
}
|
||||
|
||||
// Fase 4: indekser det overlevende settet (rot + alle nivaaer).
|
||||
generateIndexes(resolvedBundle);
|
||||
|
||||
// Fase 5 (belte+seler): alle overlevende skrevne filer maa passere scoped
|
||||
// strict. Feil her er et internt invariant-brudd, ikke en dokument-feil.
|
||||
const allWritten = survivors.flatMap((s) => s.written);
|
||||
if (allWritten.length > 0) {
|
||||
const final = checkBundle(resolvedBundle, { strictIngest: true, files: allWritten });
|
||||
const finalErrors = [...final.missingType, ...final.strictErrors];
|
||||
if (finalErrors.length > 0) {
|
||||
throw new Error(`innboks-ingest: intern invariant brutt etter relasjons-fasen: ${finalErrors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ingested, skipped, failed, written: allWritten };
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
const isMain = process.argv[1]
|
||||
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
const [inboxDir, bundleRoot] = process.argv.slice(2);
|
||||
if (!inboxDir || !bundleRoot) {
|
||||
process.stderr.write('Bruk: node innboks-ingest.mjs <innboks-katalog> <bundle-rot>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(inboxDir) || !existsSync(bundleRoot)) {
|
||||
process.stderr.write(`Finnes ikke: ${existsSync(inboxDir) ? bundleRoot : inboxDir}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const r = await ingestInbox(inboxDir, bundleRoot);
|
||||
const lines = [`Innboks-ingest: ${inboxDir} -> ${bundleRoot}`];
|
||||
lines.push(` Ingested: ${r.ingested.length} dokument(er)${r.ingested.length ? ` (${r.ingested.join(', ')})` : ''}`);
|
||||
lines.push(` Skippet (ukjent filtype): ${r.skipped.length}`);
|
||||
lines.push(` Feilet/discardet: ${r.failed.length}`);
|
||||
for (const f of r.failed) lines.push(` x ${f.source}: ${f.reason}`);
|
||||
lines.push(r.failed.length === 0 ? 'OK: alle dokumenter ingested' : 'FEIL: se discardede dokumenter over');
|
||||
process.stdout.write(`${lines.join('\n')}\n`);
|
||||
process.exit(r.failed.length === 0 ? 0 : 1);
|
||||
} catch (e) {
|
||||
process.stderr.write(`innboks-ingest: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
246
tests/innboks-ingest.test.mjs
Normal file
246
tests/innboks-ingest.test.mjs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// innboks-ingest.test.mjs
|
||||
// Step 12 (A2): end-to-end-test av pipeline-orkestratoren scripts/innboks-ingest.mjs.
|
||||
// Kjoeres i mkdtempSync UTENFOR .claude/ med innboksen NESTET under bundle-rota
|
||||
// (<tmp>/innboks inni <tmp>) saa walk-skippet ekserseres end-to-end. Verifiserer
|
||||
// (plan.md Step 12, verifies 1-8):
|
||||
// (1) idempotens: 2 kjoeringer med ULIK veggklokke -> sha256-manifest deepEqual
|
||||
// (by-construction-determinisme via original-mtime, ikke cache)
|
||||
// (2) konformitet: okf-check subprosess BAADE default OG --strict-ingest exit 0
|
||||
// (3) index-integritet: generateIndexes 2x byte-identisk OG rot-index lister
|
||||
// ikke innboks/
|
||||
// (4) original-bevaring: drop-zone-originalene er byte-uendret (sha256)
|
||||
// (5) >= 1 relasjon: kryss-dokument tittel-omtale (dok-a -> dok-b) emitteres
|
||||
// som bundle-root-relativ .md-lenke (SC-regex)
|
||||
// (6) .txt-only full-pipeline kjoerer groent zero-dep (ingen npm-deps)
|
||||
// (7) strict-scoping (B2, laast A0): pre-eksisterende kuratert fil med ekstern
|
||||
// lenke + out-of-vocab-type feller IKKE kjoeringen; kuratert fil uroert
|
||||
// (8) per-dokument-staging/gate (laast A0): multi-doc-drop der ett dok feiler
|
||||
// gaten -> KUN det dokumentet discardes (ingen spor i tre/index), det
|
||||
// andre bestaar
|
||||
// Binaer-originaler (.docx/.pdf) testes conditional { skip: !engines } -- B1-
|
||||
// aksept: en ren kjoering med ikke-.md-original passerer strict.
|
||||
// Moenster: tests/okf-check.test.mjs (subprosess-exit) + innboks-write.test.mjs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync,
|
||||
existsSync, rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { ingestInbox } from '../scripts/innboks-ingest.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CHECK = join(ROOT, 'scripts', 'okf-check.mjs');
|
||||
const SAMPLE = join(ROOT, 'tests', 'fixtures', 'inbox-sample');
|
||||
|
||||
// Er binaer-konverterings-motorene installert? (npm-deps = dokumentert prerequisite.)
|
||||
async function enginesAvailable() {
|
||||
try {
|
||||
await import('mammoth');
|
||||
await import('turndown');
|
||||
await import('unpdf');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const engines = await enginesAvailable();
|
||||
|
||||
// Temp-bundle: bundle-rot = <tmp>, drop-zone = <tmp>/innboks (nestet, som prod).
|
||||
async function withBundle(fn) {
|
||||
const bundleRoot = mkdtempSync(join(tmpdir(), 'okringest-'));
|
||||
const inbox = join(bundleRoot, 'innboks');
|
||||
mkdirSync(inbox, { recursive: true });
|
||||
try {
|
||||
await fn({ bundleRoot, inbox });
|
||||
} finally {
|
||||
rmSync(bundleRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
// Rekursivt sha256-manifest av bundlen: relativ sti -> hash (deterministisk sortert).
|
||||
function manifest(root) {
|
||||
const out = {};
|
||||
const walk = (dir) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else out[relative(root, p)] = sha256(readFileSync(p));
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Alle .md-filer under root (rekursivt), relativ sti.
|
||||
function mdFiles(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.name.endsWith('.md')) out.push(relative(root, p));
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function runCheck(root, ...extra) {
|
||||
try {
|
||||
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 || ''}` };
|
||||
}
|
||||
}
|
||||
|
||||
function dropTxtFixtures(inbox) {
|
||||
copyFileSync(join(SAMPLE, 'dok-a.txt'), join(inbox, 'dok-a.txt'));
|
||||
copyFileSync(join(SAMPLE, 'dok-b.txt'), join(inbox, 'dok-b.txt'));
|
||||
}
|
||||
|
||||
test('ingest: .txt-only full pipeline zero-dep + konformitet + relasjon + original-bevaring (1,2,4,5,6)', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
dropTxtFixtures(inbox);
|
||||
const originalsBefore = {
|
||||
a: sha256(readFileSync(join(inbox, 'dok-a.txt'))),
|
||||
b: sha256(readFileSync(join(inbox, 'dok-b.txt'))),
|
||||
};
|
||||
|
||||
const run1 = await ingestInbox(inbox, bundleRoot);
|
||||
assert.equal(run1.failed.length, 0, `ingen feilede dokumenter: ${JSON.stringify(run1.failed)}`);
|
||||
assert.equal(run1.ingested.length, 2, 'begge .txt-dokumenter ingested');
|
||||
const m1 = manifest(bundleRoot);
|
||||
|
||||
// (1) Idempotens: run2 paa ULIK veggklokke -> byte-identisk bundle.
|
||||
const run2 = await ingestInbox(inbox, bundleRoot);
|
||||
assert.equal(run2.failed.length, 0, 'run2 feiler ikke');
|
||||
assert.deepEqual(manifest(bundleRoot), m1, 'sha256-manifest run1 vs run2 identisk (idempotens by construction)');
|
||||
|
||||
// (2) Konformitet: default OG strict-ingest exit 0 (subprosess, kontrakt).
|
||||
assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0');
|
||||
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'okf-check --strict-ingest exit 0');
|
||||
|
||||
// (4) Original-bevaring: drop-zonen er byte-uendret.
|
||||
assert.equal(sha256(readFileSync(join(inbox, 'dok-a.txt'))), originalsBefore.a, 'dok-a.txt uendret');
|
||||
assert.equal(sha256(readFileSync(join(inbox, 'dok-b.txt'))), originalsBefore.b, 'dok-b.txt uendret');
|
||||
|
||||
// (5) >= 1 relasjon: kryss-dokument tittel-omtale emitteres som trygg lenke.
|
||||
const all = mdFiles(bundleRoot)
|
||||
.map((f) => readFileSync(join(bundleRoot, f), 'utf8'))
|
||||
.join('\n');
|
||||
assert.match(all, /\]\(\/[^)]*\.md\)/, 'minst en bundle-root-relativ .md-relasjon emittert');
|
||||
assert.match(all, /## Relaterte dokumenter/, 'relasjons-seksjon skrevet');
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest: index-integritet -- regen byte-identisk + rot-index lister ikke innboks/ (3)', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
dropTxtFixtures(inbox);
|
||||
await ingestInbox(inbox, bundleRoot);
|
||||
|
||||
const rootIndex = readFileSync(join(bundleRoot, 'index.md'), 'utf8');
|
||||
assert.doesNotMatch(rootIndex, /innboks/, 'rot-index lister ikke drop-zonen');
|
||||
|
||||
const { generateIndexes } = await import('../scripts/okf-index.mjs');
|
||||
const before = manifest(bundleRoot);
|
||||
generateIndexes(bundleRoot);
|
||||
assert.deepEqual(manifest(bundleRoot), before, 'generateIndexes re-kjoert er byte-identisk');
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest: strict-gaten scopes til kjoeringens filer -- kuratert innhold feller ikke (7)', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
// Pre-eksisterende kuratert fil: out-of-vocab-type + ekstern lenke er LOVLIG
|
||||
// paa lese-siden -- den skal aldri felle ingestion (B2).
|
||||
const curatedPath = join(bundleRoot, 'syklus', 'kuratert.md');
|
||||
mkdirSync(dirname(curatedPath), { recursive: true });
|
||||
const curated = '---\ntype: Egendefinert\ntitle: Kuratert notat\n---\n'
|
||||
+ 'Se [veilederen](https://www.regjeringen.no/veileder) for detaljer.\n';
|
||||
writeFileSync(curatedPath, curated);
|
||||
|
||||
dropTxtFixtures(inbox);
|
||||
const run = await ingestInbox(inbox, bundleRoot);
|
||||
assert.equal(run.failed.length, 0, 'kuratert fil feller ikke kjoeringen (scoped strict)');
|
||||
assert.equal(run.ingested.length, 2, 'begge dokumenter ingested');
|
||||
assert.equal(readFileSync(curatedPath, 'utf8'), curated, 'kuratert fil byte-uroert');
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest: per-dokument-staging/gate -- fiendtlig dok discardes alene, uten spor (8)', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
dropTxtFixtures(inbox);
|
||||
// Dangling bundle-lenke overlever noeytralisering (trygg FORM, mangler paa
|
||||
// disk) -> strict-gaten feller dokumentet deterministisk.
|
||||
writeFileSync(
|
||||
join(inbox, 'fiendtlig.txt'),
|
||||
'# Fiendtlig notat\n\nSe [detaljer](/dokumenter/finnes-ikke.md) for mer.\n',
|
||||
);
|
||||
|
||||
const run = await ingestInbox(inbox, bundleRoot);
|
||||
assert.equal(run.failed.length, 1, 'noeyaktig ett dokument discardet');
|
||||
assert.match(run.failed[0].source, /fiendtlig/, 'det fiendtlige dokumentet');
|
||||
assert.equal(run.ingested.length, 2, 'de to legitime dokumentene bestaar (Map-Reduce-isolasjon)');
|
||||
|
||||
// Ingen spor: verken konsept-fil, peker-fil eller index-oppfoering.
|
||||
const rest = mdFiles(bundleRoot).map((f) => `${f}\n${readFileSync(join(bundleRoot, f), 'utf8')}`).join('\n');
|
||||
assert.doesNotMatch(rest, /fiendtlig/i, 'ingen spor av discardet dokument i tre/index');
|
||||
|
||||
// Treet som bestaar er fortsatt konformt (default + strict).
|
||||
assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0 etter discard');
|
||||
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 etter discard');
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest: ukjent extension skippes med notice, feller ikke kjoeringen', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
dropTxtFixtures(inbox);
|
||||
writeFileSync(join(inbox, 'regneark.xlsx'), 'ikke-stoettet');
|
||||
|
||||
const notices = [];
|
||||
const run = await ingestInbox(inbox, bundleRoot, { onNotice: (m) => notices.push(m) });
|
||||
assert.equal(run.skipped.length, 1, 'xlsx skippet');
|
||||
assert.equal(run.failed.length, 0, 'skip er ikke feil');
|
||||
assert.equal(run.ingested.length, 2, 'txt-dokumentene ingested');
|
||||
assert.ok(notices.some((n) => n.includes('xlsx')), 'norsk notice om ukjent filtype');
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest: binaer-original (.docx/.pdf) passerer strict -- pekerfil uten lenkeform (B1-aksept)', { skip: !engines }, async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
copyFileSync(join(SAMPLE, 'dok.docx'), join(inbox, 'dok.docx'));
|
||||
copyFileSync(join(SAMPLE, 'dok.pdf'), join(inbox, 'dok.pdf'));
|
||||
|
||||
const run = await ingestInbox(inbox, bundleRoot);
|
||||
assert.equal(run.failed.length, 0, `binaer-ingest feiler ikke: ${JSON.stringify(run.failed)}`);
|
||||
assert.equal(run.ingested.length, 2, 'begge binaer-dokumenter ingested');
|
||||
|
||||
// B1-aksept: hele bundlen passerer strict med ikke-.md-originaler.
|
||||
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 med .docx/.pdf-originaler');
|
||||
|
||||
// Pekerfiler finnes og baerer original-stien i resource (uten lenkeform).
|
||||
const pointers = mdFiles(bundleRoot).filter((f) => f.endsWith('.kilde.md'));
|
||||
assert.equal(pointers.length, 2, 'en pekerfil per original');
|
||||
for (const p of pointers) {
|
||||
const content = readFileSync(join(bundleRoot, p), 'utf8');
|
||||
assert.match(content, /^resource: innboks\/dok[^\n]*$/m, 'resource baerer original-stien');
|
||||
assert.doesNotMatch(content, /\]\(/, 'ingen lenkeform i pekerfil (B1)');
|
||||
}
|
||||
|
||||
// Idempotens gjelder ogsaa binaer-stien (convert-twice-identical + mtime).
|
||||
const m1 = manifest(bundleRoot);
|
||||
await ingestInbox(inbox, bundleRoot);
|
||||
assert.deepEqual(manifest(bundleRoot), m1, 'binaer-ingest idempotent');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue