feat(okr): konsept-skriver m/ type-til-nivaa-ruting + original-bevaring (SC original, riktig nivaa) [skip-docs]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
This commit is contained in:
parent
2b0c520efd
commit
935c1d0ae9
2 changed files with 274 additions and 0 deletions
152
tests/innboks-write.test.mjs
Normal file
152
tests/innboks-write.test.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// innboks-write.test.mjs
|
||||
// Step 7 (SC original + riktig nivaa): konsept-skriver med type->nivaa-ruting og
|
||||
// ikke-destruktiv original-bevaring. Driver den reelle kjernen
|
||||
// (splitConcepts -> projectFrontmatter -> resolveRelations) per original og lar
|
||||
// writeConcepts persistere settet i en temp-bundle. Verifiserer:
|
||||
// - 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)
|
||||
// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist
|
||||
// - ingen .tmp lekker (atomisk temp+renameSync)
|
||||
// Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
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';
|
||||
|
||||
const MTIME = new Date('2026-03-15T08:30:00.000Z');
|
||||
|
||||
function sha256(path) {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
}
|
||||
|
||||
// Kjor den reelle kjernen for ett dokument -> konsepter (m/ destRel + frontmatter + body).
|
||||
function ingestOne(markdown, sourceSlug, sourcePath) {
|
||||
const raw = splitConcepts(markdown, { sourceSlug });
|
||||
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: MTIME }));
|
||||
return resolveRelations(projected);
|
||||
}
|
||||
|
||||
// Temp-bundle: <tmp>/.claude/okr (+ innboks/ drop-zone). Ryddes alltid.
|
||||
function withBundle(fn) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'okrwrite-'));
|
||||
const bundleRoot = join(tmp, '.claude', 'okr');
|
||||
const inbox = join(bundleRoot, 'innboks');
|
||||
mkdirSync(inbox, { recursive: true });
|
||||
try {
|
||||
fn({ tmp, bundleRoot, inbox });
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const TILDELING = '# Tildelingsbrev 2026\n\nStatens vegvesen skal levere paa foelgende maal i 2026.\n';
|
||||
const MERKNAD = '# Generell merknad\n\nEt fritt notat uten foeringsord i tittel.\n';
|
||||
|
||||
test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
const mPath = join(inbox, 'merknad.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
writeFileSync(mPath, MERKNAD);
|
||||
|
||||
const tConcepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
const mConcepts = ingestOne(MERKNAD, 'merknad', 'innboks/merknad.txt');
|
||||
const [tc] = tConcepts;
|
||||
const [mc] = mConcepts;
|
||||
|
||||
// Forventning fra Step 5-ruting (sanity foer skriv).
|
||||
assert.equal(dirname(tc.destRel), 'strategisk-kontekst', 'Tildelingsbrev -> strategisk-kontekst/');
|
||||
assert.equal(dirname(mc.destRel), 'dokumenter', 'default Dokument -> dokumenter/');
|
||||
|
||||
writeConcepts([...tConcepts, ...mConcepts], {
|
||||
bundleRoot,
|
||||
originals: [
|
||||
{ sourceSlug: 'tildeling', path: tPath },
|
||||
{ sourceSlug: 'merknad', path: mPath },
|
||||
],
|
||||
});
|
||||
|
||||
const tFile = join(bundleRoot, tc.destRel);
|
||||
const mFile = join(bundleRoot, mc.destRel);
|
||||
assert.ok(existsSync(tFile), 'tildelingsbrev-konsept skrevet til strategisk-kontekst/');
|
||||
assert.ok(existsSync(mFile), 'merknad-konsept skrevet til dokumenter/');
|
||||
|
||||
// Skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline. IKKE re-serialisert.
|
||||
const tContent = readFileSync(tFile, 'utf8');
|
||||
assert.ok(tContent.startsWith(tc.frontmatter), 'frontmatter verbatim oeverst');
|
||||
assert.ok(tContent.includes(tc.body), 'body verbatim bevart');
|
||||
assert.ok(tContent.endsWith('\n'), 'fil avsluttes med newline');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone-original', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
const before = sha256(tPath);
|
||||
|
||||
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
writeConcepts(concepts, {
|
||||
bundleRoot,
|
||||
originals: [{ sourceSlug: 'tildeling', path: tPath }],
|
||||
});
|
||||
|
||||
// SC3: ikke-destruktiv -- originalen er byte-uendret.
|
||||
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.
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: ../-escape destRel avvist (path-confinement)', () => {
|
||||
withBundle(({ bundleRoot }) => {
|
||||
const evil = { slug: 'evil', sourceSlug: 'evil', destRel: '../escape.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
|
||||
assert.throws(
|
||||
() => writeConcepts([evil], { bundleRoot, originals: [] }),
|
||||
/utenfor bundle-rot/,
|
||||
'destRel som escaper bundle-rota skal kaste',
|
||||
);
|
||||
assert.ok(!existsSync(join(bundleRoot, '..', 'escape.md')), 'ingenting skrevet utenfor bundle');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: skriv til home-org-rot avvist (ingestion roerer aldri ~/.claude/okr/org)', () => {
|
||||
const homeOrg = join(homedir(), '.claude', 'okr', 'org');
|
||||
const concept = { slug: 'p', sourceSlug: 'p', destRel: 'dokumenter/p.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
|
||||
// Kaster FOER noen skriv -- home-profilen roeres aldri.
|
||||
assert.throws(
|
||||
() => writeConcepts([concept], { bundleRoot: homeOrg, originals: [] }),
|
||||
/home-org-rot avvist/,
|
||||
'home-org bundleRoot skal kaste',
|
||||
);
|
||||
});
|
||||
|
||||
test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
writeConcepts(concepts, { bundleRoot, originals: [{ sourceSlug: 'tildeling', path: tPath }] });
|
||||
|
||||
const level = dirname(concepts[0].destRel);
|
||||
const leftover = readdirSync(join(bundleRoot, level)).filter((f) => f.endsWith('.tmp'));
|
||||
assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue