240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
#!/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);
|
|
}
|
|
}
|