// tests/kb-eval/test-skill-ops-merge-apply.test.mjs // Spor B / B3 (Sesjon 19): merge-apply — the HEAVIEST destructive op. An // operator-approved (status:approved) merge_skills entry is executed into a real // skills/ mutation: the absorbed skill's curated references MOVE under the // absorber, the taxonomy's category_skill ownership is REPOINTED + persisted, // and the now-empty absorbed shell is archived (SKILL.md) + its dir removed. // // Binding contract proven here: // 1. Curated preservation: every absorbed reference survives under the absorber // (the guardrail's set-equality made real on disk — a MOVE, not an archive). // 2. Taxonomy persistence: categories owned by the absorbed skill are repointed // to the absorber and written back to domain-taxonomy.json (drift-safe: the // reassignments are recomputed from a FRESH plan at apply time). // 3. Archive-before-delete for the absorbed shell: SKILL.md lands under archive/ // before the skills/ dir is removed; absorber + siblings untouched. // 4. Refuses (zero mutation, ledger untouched) on: collision guardrail-fail, // disk drift since approval, or a non-approved entry. // // Every scenario runs against a tmpdir fixture — NEVER the real skills/ tree. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { applyApprovedAction, runMergePlan, } from '../../scripts/kb-eval/lib/skill-ops.mjs'; import { loadTaxonomy, getCategorySkill } from '../../scripts/kb-update/lib/taxonomy.mjs'; import { loadDecisions, saveDecisions, recordAction, actionKey, } from '../../scripts/kb-update/lib/decisions-io.mjs'; // --------------------------------------------------------------------------- // Fixture: absorber (skill-a) + absorbed (skill-b) under one root, plus a // domain-taxonomy.json in dataDir whose category_skill assigns cat3 to the // absorbed skill (so the merge must repoint + persist it). archive/ lands at the // same root so rename stays on one filesystem. `extraBRef` lets a test inject a // colliding reference-relative path (present under BOTH skills). // --------------------------------------------------------------------------- function withFixture(fn, { extraBRef } = {}) { const root = mkdtempSync(join(tmpdir(), 'mergeapply-')); const skillsDir = join(root, 'skills'); const dataDir = join(root, 'data'); const mkRef = (skill, cat, file, body) => { const dir = join(skillsDir, skill, 'references', cat); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, file), body); }; const mkSkillMd = (skill, body) => { mkdirSync(join(skillsDir, skill), { recursive: true }); writeFileSync(join(skillsDir, skill, 'SKILL.md'), body); }; // absorber: 2 curated refs under cat1 mkRef('skill-a', 'cat1', 'a1.md', '# A1\n'); mkRef('skill-a', 'cat1', 'a2.md', '# A2\n'); mkSkillMd('skill-a', '# skill-a (absorber)\n'); // absorbed: 1 curated ref under cat3 mkRef('skill-b', 'cat3', 'b1.md', '# B1\n'); mkSkillMd('skill-b', '# skill-b (absorbed)\n'); if (extraBRef) mkRef('skill-b', extraBRef.cat, extraBRef.file, extraBRef.body ?? '# collide\n'); // taxonomy: cat1 owned by absorber, cat3 owned by absorbed mkdirSync(dataDir, { recursive: true }); writeFileSync( join(dataDir, 'domain-taxonomy.json'), JSON.stringify({ version: 1, category_skill: { cat1: 'skill-a', cat3: 'skill-b' } }, null, 2) + '\n', ); try { return fn({ root, skillsDir, dataDir }); } finally { rmSync(root, { recursive: true, force: true }); } } const CATEGORY_SKILL = { cat1: 'skill-a', cat3: 'skill-b' }; // Seed an operator-approved merge entry by snapshotting a dry-run's guardrail — // exactly what the operator gate would write after reviewing the dry-run. function approveMerge(absorber, absorbed, { skillsDir, dataDir, status = 'approved' }) { const { entry } = runMergePlan(absorber, absorbed, { skillsDir, dataDir, write: false, decided_at: '2026-06-20', categorySkill: CATEGORY_SKILL, }); const seeded = { ...entry, status }; saveDecisions(recordAction(loadDecisions(dataDir), seeded), dataDir); return seeded; } const applyOpts = (root, skillsDir, dataDir, apply) => ({ pluginRoot: root, skillsDir, dataDir, taxonomyDataDir: dataDir, categorySkill: CATEGORY_SKILL, apply, decided_at: apply ? '2026-06-21' : null, }); // =========================================================================== // (A) Happy path — refs moved, taxonomy persisted, absorbed archived+removed // =========================================================================== test('merge-apply — absorbed refs move under absorber; absorber + siblings untouched', () => { withFixture(({ root, skillsDir, dataDir }) => { const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, true); assert.equal(res.revalidate.ok, true); // Absorbed ref relocated under the absorber (preservation = a MOVE). assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat3/b1.md')), 'absorbed ref now under absorber'); // Absorber's own refs untouched. assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/a1.md')), 'absorber ref a1 preserved'); assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/a2.md')), 'absorber ref a2 preserved'); }); }); test('merge-apply — taxonomy ownership repointed to absorber and persisted to disk', () => { withFixture(({ root, skillsDir, dataDir }) => { const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, true); assert.equal(res.report.taxonomyReassignments, 1, 'one category (cat3) repointed'); const tax = loadTaxonomy(dataDir); assert.equal(getCategorySkill(tax, 'cat3'), 'skill-a', 'absorbed category now owned by absorber'); assert.equal(getCategorySkill(tax, 'cat1'), 'skill-a', 'absorber category unchanged'); }); }); test('merge-apply — absorbed shell archived (SKILL.md), then its dir removed; ledger applied', () => { withFixture(({ root, skillsDir, dataDir }) => { const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, true); // Archive-before-delete: the absorbed SKILL.md is recoverable under archive/. assert.ok(existsSync(join(root, 'archive/skills/skill-b/SKILL.md')), 'absorbed SKILL.md archived'); // The absorbed skill dir is gone entirely. assert.equal(existsSync(join(skillsDir, 'skill-b')), false, 'absorbed skill dir removed'); // Ledger flipped approved -> applied. assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'applied'); }); }); // =========================================================================== // (B) Safety — collision, drift, preview, non-approved // =========================================================================== test('merge-apply — a colliding reference path fails the guardrail and applies nothing', () => { withFixture( ({ root, skillsDir, dataDir }) => { // skill-b now also has cat1/a1.md — the same references-relative path as the // absorber. The merge guardrail is ok=false (would clobber on the target). const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); assert.equal(approved.guardrail.ok, false, 'precondition: collision makes the dry-run guardrail fail'); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, false, 'a failing guardrail must never reach the filesystem'); assert.equal(res.revalidate.freshOk, false); assert.ok(existsSync(join(skillsDir, 'skill-b')), 'absorbed skill untouched'); assert.equal(existsSync(join(root, 'archive')), false, 'nothing archived'); assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved', 'ledger left at approved'); }, { extraBRef: { cat: 'cat1', file: 'a1.md' } }, ); }); test('merge-apply — DRIFT after approval aborts with zero mutation and an untouched ledger', () => { withFixture(({ root, skillsDir, dataDir }) => { const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); // Disk drifts after approval: a new ref appears under the absorbed skill, so a // fresh plan's guardrail counts differ from the approved snapshot. mkdirSync(join(skillsDir, 'skill-b/references/cat3'), { recursive: true }); writeFileSync(join(skillsDir, 'skill-b/references/cat3/b2.md'), '# B2 (new)\n'); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, false, 'drift must abort the apply'); assert.equal(res.revalidate.drift, true); assert.match(res.revalidate.reason, /drift/i); assert.ok(existsSync(join(skillsDir, 'skill-b')), 'absorbed skill untouched'); assert.equal(existsSync(join(root, 'archive')), false, 'nothing archived on a drift abort'); assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved'); // Taxonomy untouched too. assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b'); }); }); test('merge-apply — preview (apply:false) mutates nothing; ledger + taxonomy unchanged', () => { withFixture(({ root, skillsDir, dataDir }) => { const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, false)); assert.equal(res.applied, false); assert.equal(res.preview, true); assert.equal(res.revalidate.ok, true, 'disk unchanged -> revalidation would pass'); assert.ok(existsSync(join(skillsDir, 'skill-b/references/cat3/b1.md')), 'absorbed ref still in place (preview only)'); assert.equal(existsSync(join(root, 'archive')), false, 'preview archives nothing'); assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b', 'taxonomy unchanged in preview'); assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved'); }); }); test('merge-apply — refuses a non-approved (pending) entry with zero mutation', () => { withFixture(({ root, skillsDir, dataDir }) => { const pending = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir, status: 'pending' }); const res = applyApprovedAction(pending, applyOpts(root, skillsDir, dataDir, true)); assert.equal(res.applied, false); assert.match(res.revalidate.reason, /approved/i); assert.ok(existsSync(join(skillsDir, 'skill-b')), 'no mutation'); assert.equal(existsSync(join(root, 'archive')), false); assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b'); }); });