fix(okr): B1 pekerfil uten lenkeform + B5 kollisjons-guards (kuratert-vern, reservert index.md, kryss-kilde claimed) [skip-docs]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:27:55 +02:00
commit 188b534a3b
2 changed files with 124 additions and 10 deletions

View file

@ -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<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}`;
@ -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);
}

View file

@ -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, /<a\b|<[a-z][a-z0-9+.-]*:/i, 'ingen HTML-anker/autolink i pekerfila (B1)');
assert.match(pointerContent, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)');
assert.match(pointerContent, /^kilde: innboks$/m, 'peker baerer kilde:innboks (provenans + B5-eierskap)');
});
});
// --- B5 (A2): kollisjons-guards -- treet selv skal vaere uskjermet aldri mer ---
const CURATED = '---\ntype: Status\ntitle: Kuratert status\n---\nHaandskrevet innhold som ALDRI skal overskrives.\n';
function concept(slug, destRel, sourceSlug = 'kilde-a') {
return {
slug,
sourceSlug,
destRel,
frontmatter: '---\ntype: Dokument\nkilde: innboks\n---\n',
body: 'innhold',
};
}
test('writeConcepts: nekter aa overskrive pre-eksisterende kuratert fil (B5)', () => {
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 });
});
});