186 lines
9.4 KiB
JavaScript
186 lines
9.4 KiB
JavaScript
// innboks-write.mjs
|
|
// Step 7: persister berikede konsepter (splitConcepts -> projectFrontmatter ->
|
|
// resolveRelations) inn i prosjekt-bundlen, ikke-destruktivt og idempotent.
|
|
//
|
|
// (1) Hvert konsept skrives til sin FORHAANDSBEREGNEDE concept.destRel (rutet
|
|
// via routeLevel i Step 5 -- skriveren ruter ikke selv). Skrevet fil =
|
|
// concept.frontmatter (ferdig OKF-blokk) + concept.body (baerer allerede
|
|
// relasjons-seksjonen) verbatim -- INGEN re-serialisering. Trailing newline
|
|
// garanteres (POSIX-ren, deterministisk -> byte-idempotent re-kjoering).
|
|
// (2) Atomisk skriv: temp i SAMME katalog + renameSync over maalet (en krasj
|
|
// midt i skriv kan aldri etterlate en halv-skrevet fil).
|
|
// (3) Original-bevaring (SC3, ikke-destruktiv): skriveren roerer ALDRI
|
|
// drop-zone-originalene -- den asserterer kun at de finnes (saa pekeren
|
|
// aldri dangler). Per original skrives EN markdown-peker i konseptets nivaa
|
|
// som lenker bundle-root-relativt til originalen i .claude/okr/innboks/.
|
|
// (4) Path-confinement (innboks-dok = fiendtlig, RAG-poisoning): hver maal-sti
|
|
// maa resolvere UNDER bundle-rota (avvis '..'-escape / absolutt-override),
|
|
// og bundle-rota selv maa ikke vaere den home-kanoniske org-profilen
|
|
// (~/.claude/okr/org) -- ingestion skriver kun i prosjekt-bundlen.
|
|
// (5) B5-kollisjons-guards (A2) -- treet selv er skjermet, ikke bare originalene:
|
|
// - reservert navn: destRel med basename index.md avvises (indeksering
|
|
// eier index.md; et konsept-slug «index» ville blitt destruert ved
|
|
// neste generateIndexes).
|
|
// - kuratert-fil-vern: eksisterende maal-fil UTEN `kilde: innboks` i
|
|
// frontmatter er haandkuratert -> skriv avvises (aldri stille datatap).
|
|
// Med `kilde: innboks` er fila ingestion-eid -> re-skriv OK (idempotent
|
|
// re-ingest av samme drop-zone).
|
|
// - kryss-kilde-kollisjon: opts.claimed (Map maal -> sourceSlug, delt av
|
|
// orkestratoren PAA TVERS av per-dokument-kall) avviser at to KILDER
|
|
// skriver samme destRel i samme kjoering (stille last-wins var B5);
|
|
// samme kilde kan re-skrive (relasjons-fase 2).
|
|
//
|
|
// Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40);
|
|
// writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps.
|
|
|
|
import { writeFileSync, readFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
import { writeFrontmatter, parseFrontmatter } from './frontmatter.mjs';
|
|
|
|
// Den home-kanoniske org-profil-rota. Ingestion skal ALDRI skrive hit (den eies
|
|
// av write-org-profile.mjs); prosjekt-bundlen er .claude/okr under cwd.
|
|
const HOME_ORG = path.join(homedir(), '.claude', 'okr', 'org');
|
|
|
|
// Atomisk: temp-fil i samme katalog, deretter renameSync over maalet (atomisk
|
|
// paa samme filsystem). Speiler write-org-profile.mjs:34-40.
|
|
function writeAtomic(target, data) {
|
|
const dir = path.dirname(target);
|
|
mkdirSync(dir, { recursive: true });
|
|
const tmp = path.join(dir, `${path.basename(target)}.${process.pid}.tmp`);
|
|
writeFileSync(tmp, data);
|
|
renameSync(tmp, target);
|
|
}
|
|
|
|
// Resolver en bundle-relativ sti og asserter at den blir UNDER bundle-rota.
|
|
// Avviser '..'-escape og absolutt-override (path.resolve lar en absolutt rel
|
|
// vinne -- containment-sjekken fanger det).
|
|
function resolveUnderBundle(resolvedBundle, rel) {
|
|
const resolved = path.resolve(resolvedBundle, rel);
|
|
if (resolved !== resolvedBundle && !resolved.startsWith(resolvedBundle + path.sep)) {
|
|
throw new Error(`innboks-write: maal-sti utenfor bundle-rot avvist: ${rel}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
// M2 (A1): den leksikalske sjekken over slipper symlinks -- en symlinket
|
|
// katalog/original INNE i bundlen kan peke UT av den. realpathSync paa den
|
|
// faktiske noden (destinasjons-parent etter mkdir / original foer peker-skriv)
|
|
// maa ogsaa lande under bundle-rotas realpath, ellers avvises skrivet.
|
|
function assertRealUnderBundle(realBundle, p, what) {
|
|
const real = realpathSync(p);
|
|
if (real !== realBundle && !real.startsWith(realBundle + path.sep)) {
|
|
throw new Error(`innboks-write: ${what} resolverer utenfor bundle-rot (symlink-escape avvist): ${p}`);
|
|
}
|
|
return real;
|
|
}
|
|
|
|
// Destinasjons-parent opprettes, realpath-sjekkes, DERETTER skrives det atomisk.
|
|
function writeConfined(realBundle, target, data, what) {
|
|
const dir = path.dirname(target);
|
|
mkdirSync(dir, { recursive: true });
|
|
assertRealUnderBundle(realBundle, dir, what);
|
|
writeAtomic(target, data);
|
|
}
|
|
|
|
// B5-guards foer skriv (se header (5)). claimed: Map<absolutt maal, sourceSlug>.
|
|
function guardTarget(target, sourceSlug, claimed, rel) {
|
|
if (path.basename(target) === 'index.md') {
|
|
throw new Error(`innboks-write: reservert navn avvist (index.md eies av indekseringen): ${rel}`);
|
|
}
|
|
const owner = claimed.get(target);
|
|
if (owner !== undefined && owner !== sourceSlug) {
|
|
throw new Error(
|
|
`innboks-write: kryss-kilde destRel-kollisjon: ${rel} alt skrevet av kilde «${owner}» i denne kjoeringen (naa: «${sourceSlug}»)`,
|
|
);
|
|
}
|
|
if (owner === undefined && existsSync(target)) {
|
|
const { get } = parseFrontmatter(readFileSync(target, 'utf8'));
|
|
if (get('kilde') !== 'innboks') {
|
|
throw new Error(`innboks-write: nekter aa overskrive kuratert (ikke-ingestion) fil: ${rel}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Skrevet fil = frontmatter + body verbatim, med garantert trailing newline.
|
|
function fileContent(concept) {
|
|
const out = `${concept.frontmatter}${concept.body}`;
|
|
return out.endsWith('\n') ? out : `${out}\n`;
|
|
}
|
|
|
|
// Peker-fil: minimal gyldig OKF-fil (type i lukket vokab) som lenker til den
|
|
// bevarte originalen. resource = bundle-relativ original-sti (uten leading '/').
|
|
function pointerContent(original, link) {
|
|
const basename = path.basename(original.path);
|
|
const frontmatter = writeFrontmatter({
|
|
type: 'Notat',
|
|
resource: link.replace(/^\/+/, ''),
|
|
title: `Kilde: ${basename}`,
|
|
kilde: 'innboks',
|
|
});
|
|
// B1: INGEN lenkeform i body -- en md-lenke til en ikke-.md-original feller
|
|
// strict-gaten (okf-links krever .md). resource: over baerer stien; body
|
|
// nevner den kun som ren tekst (grep-bar, aldri lenke).
|
|
const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion): ${link.replace(/^\/+/, '')}\n`;
|
|
return `${frontmatter}${body}`;
|
|
}
|
|
|
|
// concepts (berikede, m/ destRel + frontmatter + body) + { bundleRoot, originals }
|
|
// -> skriver konsept-filer + peker-filer atomisk under bundle-rota.
|
|
// originals: [{ sourceSlug, path }] -- path = originalens plassering i drop-zonen.
|
|
// Returnerer { concepts: [skrevne konsept-stier], pointers: [skrevne peker-stier] }
|
|
// (absolutte stier; pipelinen (Step 10) bruker dette til discard-on-fail rollback).
|
|
export function writeConcepts(concepts, { bundleRoot, originals = [], claimed = new Map() } = {}) {
|
|
if (!bundleRoot) throw new Error('innboks-write: bundleRoot kreves');
|
|
const resolvedBundle = path.resolve(bundleRoot);
|
|
|
|
// Confinement: avvis skriv til home-org-rota -- ingestion eier kun prosjekt-bundlen.
|
|
if (resolvedBundle === HOME_ORG || resolvedBundle.startsWith(HOME_ORG + path.sep)) {
|
|
throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`);
|
|
}
|
|
|
|
// Bundle-rotas realpath er ankeret for alle M2-symlink-sjekker under.
|
|
const realBundle = realpathSync(resolvedBundle);
|
|
|
|
const writtenConcepts = [];
|
|
for (const concept of concepts) {
|
|
if (!concept.destRel) {
|
|
throw new Error(`innboks-write: konsept mangler destRel: ${concept.slug ?? '?'}`);
|
|
}
|
|
const target = resolveUnderBundle(resolvedBundle, concept.destRel);
|
|
guardTarget(target, concept.sourceSlug, claimed, concept.destRel);
|
|
writeConfined(realBundle, target, fileContent(concept), `destinasjons-katalog for ${concept.destRel}`);
|
|
claimed.set(target, concept.sourceSlug);
|
|
writtenConcepts.push(target);
|
|
}
|
|
|
|
const writtenPointers = [];
|
|
for (const original of originals) {
|
|
const resolvedOriginal = path.resolve(original.path);
|
|
if (!existsSync(resolvedOriginal)) {
|
|
throw new Error(`innboks-write: original mangler (peker ville dangle): ${original.path}`);
|
|
}
|
|
// Original maa ligge under bundle-rota (drop-zonen .claude/okr/innboks/).
|
|
const relToBundle = path.relative(resolvedBundle, resolvedOriginal);
|
|
if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) {
|
|
throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`);
|
|
}
|
|
// M2: en symlink-original i drop-zonen passerer den leksikalske sjekken
|
|
// over, men realpathen kan peke UT av bundlen -> avvis.
|
|
assertRealUnderBundle(realBundle, resolvedOriginal, `original ${original.path}`);
|
|
const link = `/${relToBundle.split(path.sep).join('/')}`;
|
|
|
|
// Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/.
|
|
const sibling = concepts.find((c) => c.sourceSlug === original.sourceSlug && c.destRel);
|
|
const level = sibling ? path.dirname(sibling.destRel) : 'dokumenter';
|
|
const pointerRel = path.join(level, `${original.sourceSlug}.kilde.md`);
|
|
const pointerTarget = resolveUnderBundle(resolvedBundle, pointerRel);
|
|
guardTarget(pointerTarget, original.sourceSlug, claimed, pointerRel);
|
|
writeConfined(realBundle, pointerTarget, pointerContent(original, link), `peker-katalog for ${pointerRel}`);
|
|
claimed.set(pointerTarget, original.sourceSlug);
|
|
writtenPointers.push(pointerTarget);
|
|
}
|
|
|
|
return { concepts: writtenConcepts, pointers: writtenPointers };
|
|
}
|