diff --git a/lib/innboks-write.mjs b/lib/innboks-write.mjs index 3591cba..e343918 100644 --- a/lib/innboks-write.mjs +++ b/lib/innboks-write.mjs @@ -17,15 +17,27 @@ // 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, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { homedir } from 'node:os'; -import { writeFrontmatter } from './frontmatter.mjs'; +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. @@ -72,6 +84,25 @@ function writeConfined(realBundle, target, data, what) { writeAtomic(target, data); } +// B5-guards foer skriv (se header (5)). claimed: Map. +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}`; @@ -88,7 +119,10 @@ function pointerContent(original, link) { title: `Kilde: ${basename}`, kilde: 'innboks', }); - const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion).\n\n- [${basename}](${link})\n`; + // 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}`; } @@ -97,7 +131,7 @@ function pointerContent(original, link) { // 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 = [] } = {}) { +export function writeConcepts(concepts, { bundleRoot, originals = [], claimed = new Map() } = {}) { if (!bundleRoot) throw new Error('innboks-write: bundleRoot kreves'); const resolvedBundle = path.resolve(bundleRoot); @@ -115,7 +149,9 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { 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); } @@ -140,7 +176,9 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { 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); } diff --git a/tests/innboks-write.test.mjs b/tests/innboks-write.test.mjs index 1d09766..0f5a84e 100644 --- a/tests/innboks-write.test.mjs +++ b/tests/innboks-write.test.mjs @@ -6,9 +6,13 @@ // - konsept med strategisk-kontekst/-destRel skrives dit, dokumenter/-destRel dit // - skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline // - originalens sha256 er uendret etter skriv (ikke-destruktiv, SC3) -// - peker-fil i konseptets nivaa lenker til originalen i drop-zonen (grep-bar) +// - peker-fil i konseptets nivaa baerer original-stien i resource: UTEN noen +// lenkeform i body (B1: en body-lenke til ikke-.md-original feller strict) // - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist // - ingen .tmp lekker (atomisk temp+renameSync) +// - B5-guards: nekter overskriving av kuratert (ikke-ingestion) fil; tillater +// idempotent re-skriv av egen kilde:innboks-output; avviser reservert navn +// (index.md); avviser kryss-kilde destRel-kollisjon via claimed-registeret // Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha). import { test } from 'node:test'; @@ -89,7 +93,7 @@ test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () }); }); -test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone-original', () => { +test('writeConcepts: original uendret (sha256) + peker-fil uten lenkeform (B1)', () => { withBundle(({ bundleRoot, inbox }) => { const tPath = join(inbox, 'tildeling.txt'); writeFileSync(tPath, TILDELING); @@ -105,13 +109,85 @@ test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone- assert.equal(sha256(tPath), before, 'original-sha256 uendret etter skriv'); assert.ok(existsSync(tPath), 'original finnes fortsatt i drop-zonen'); - // Peker-fil i konseptets nivaa, grep-bar lenke til originalen. + // Peker-fil i konseptets nivaa: resource baerer stien, body har INGEN + // lenkeform (B1: `- [x.txt](/innboks/x.txt)` er ikke .md -> feller strict). const level = dirname(concepts[0].destRel); const pointer = join(bundleRoot, level, 'tildeling.kilde.md'); assert.ok(existsSync(pointer), 'peker-fil skrevet i konseptets nivaa'); - const pointerBody = readFileSync(pointer, 'utf8'); - assert.match(pointerBody, /\(\/innboks\/tildeling\.txt\)/, 'peker lenker til original i drop-zonen'); - assert.match(pointerBody, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)'); + const pointerContent = readFileSync(pointer, 'utf8'); + assert.match(pointerContent, /^resource: innboks\/tildeling\.txt$/m, 'resource baerer original-stien'); + assert.match(pointerContent, /innboks\/tildeling\.txt/, 'original-stien er grep-bar'); + assert.doesNotMatch(pointerContent, /\]\(/, 'ingen inline/bilde-lenke i pekerfila (B1)'); + assert.doesNotMatch(pointerContent, /^[ \t]{0,3}\[[^\]]+\]:/m, 'ingen referanse-definisjon i pekerfila (B1)'); + assert.doesNotMatch(pointerContent, / { + withBundle(({ bundleRoot }) => { + const curatedPath = join(bundleRoot, 'dokumenter', 'status.md'); + mkdirSync(dirname(curatedPath), { recursive: true }); + writeFileSync(curatedPath, CURATED); + + assert.throws( + () => writeConcepts([concept('status', 'dokumenter/status.md')], { bundleRoot }), + /kuratert|ikke-ingestion|overskriv/i, + 'skriv over kuratert fil (uten kilde:innboks) skal kaste', + ); + assert.equal(readFileSync(curatedPath, 'utf8'), CURATED, 'kuratert fil er byte-uendret'); + }); +}); + +test('writeConcepts: tillater idempotent re-skriv av egen kilde:innboks-fil (B5)', () => { + withBundle(({ bundleRoot }) => { + const c = concept('notat', 'dokumenter/notat.md'); + writeConcepts([c], { bundleRoot }); + // Re-ingest (run2): samme fil skrives igjen -- skal IKKE kaste. + writeConcepts([c], { bundleRoot }); + const written = readFileSync(join(bundleRoot, 'dokumenter', 'notat.md'), 'utf8'); + assert.ok(written.includes('kilde: innboks'), 'ingestion-eierskap staar i skrevet fil'); + }); +}); + +test('writeConcepts: reservert navn index.md avvises (B5)', () => { + withBundle(({ bundleRoot }) => { + assert.throws( + () => writeConcepts([concept('index', 'dokumenter/index.md')], { bundleRoot }), + /reservert|index/i, + 'destRel med basename index.md skal kaste (indeksering eier index.md)', + ); + assert.ok(!existsSync(join(bundleRoot, 'dokumenter', 'index.md')), 'ingenting skrevet'); + }); +}); + +test('writeConcepts: kryss-kilde destRel-kollisjon avvises via claimed-register (B5)', () => { + withBundle(({ bundleRoot }) => { + const claimed = new Map(); + writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed }); + // Annen kilde, samme destRel -> kollisjon (stille last-wins var B5-datatapet). + assert.throws( + () => writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-b')], { bundleRoot, claimed }), + /kollisjon|kilde/i, + 'samme destRel fra annen kilde i samme kjoering skal kaste', + ); + // Samme kilde igjen (relasjons-omskriv i fase 2) -> OK. + writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed }); }); });