Merge-apply = den tyngste destruktive op-en: eksekver en operatør-godkjent merge_skills-entry → faktisk cross-skill skills/-mutasjon. Tre mekanismer som sanitize/retire ikke trengte: kuratert relokasjon (ikke arkiv), taksonomi-persistering, retire-av-absorbert. - taxonomy.mjs (ny persist): saveTaxonomy (atomisk .tmp+rename, speiler saveDecisions) + ren applyCategoryReassignments (repointer KUN der eier === from; absent/allerede-flyttet = no-op). Lag-0 er normalt read-only; ENESTE writer = gated merge-apply. - applyApprovedAction merge-gren (skill-ops.mjs): re-plan fersk → revalidér (kollisjon → guardrail.ok=false → abort) → flytt absorbed-refs → absorber (moveFile; flyttingen ER bevaringen set-equality beviser) → persister taksonomi-repoint fra FERSK plan (drift-sikker) → arkivér absorbed-SKILL.md FØR rmdir → flipp ledger approved→applied. Description-forsoning forblir MANUELL (planner-kontrakt: apply rører aldri absorber-SKILL.md). archiveMove→moveFile (generisk: arkiv + kuratert flytt). - CLI apply-skill-op.mjs merge <absorber> <absorbed> — lookup via actionKey (merge_skills:<sortert par>); default PREVIEW, --apply = dobbel-gate. TDD, tmpdir-fixtures: 0 ekte skills/-mutasjon. Ny test-skill-ops-merge-apply (7) + 2 taksonomi-tester; fjernet foreldet merge-throws fra S18-fila. Tester: kb-eval 78→84, kb-update 137→139; validate 239 · kb-integrity 192/192 uendret. eval --json deterministisk uendret (K10 eng+infra FAIL, øvrige PASS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
// tests/kb-eval/test-skill-ops-apply.test.mjs
|
|
// Spor B / B3 (Sesjon 18): the APPLY-PATH — the destructive frontier. An
|
|
// operator-approved (status:approved) ledger entry is executed into a real
|
|
// skills/ mutation. This file covers the SINGLE-SKILL ops (sanitize + retire);
|
|
// merge-apply (cross-skill, Sesjon 19) lives in test-skill-ops-merge-apply.test.mjs;
|
|
// create_skill comes last.
|
|
//
|
|
// Binding contract proven here:
|
|
// 1. revalidateApply (pure) re-checks an approved entry against a FRESH plan:
|
|
// status must be approved, op-type must match, fresh guardrail must be ok,
|
|
// and the fresh guardrail must deep-equal the approved snapshot (no drift).
|
|
// 2. applyApprovedAction archives BEFORE it deletes (rename is an atomic
|
|
// archive+remove): dead content / whole skills land under archive/ first.
|
|
// 3. Disk drift since approval (or a non-approved / guardrail-failing entry)
|
|
// ABORTS with zero mutation and the ledger left untouched.
|
|
// 4. A successful apply flips the ledger entry approved -> applied.
|
|
//
|
|
// 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 {
|
|
revalidateApply,
|
|
applyApprovedAction,
|
|
runSanitizePlan,
|
|
runRetirePlan,
|
|
planSanitizeSkill,
|
|
SANITIZE_OPERATION,
|
|
RETIRE_OPERATION,
|
|
} from '../../scripts/kb-eval/lib/skill-ops.mjs';
|
|
import {
|
|
loadDecisions,
|
|
saveDecisions,
|
|
recordAction,
|
|
actionKey,
|
|
} from '../../scripts/kb-update/lib/decisions-io.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture: a skills/ tree + agents/ dir under one root, so archive/ lands at
|
|
// the same root (rename stays on one filesystem). skill-a has a curated
|
|
// (referenced) ref + a dead (orphan) ref; skill-b is a small skill to retire.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function withFixture(fn) {
|
|
const root = mkdtempSync(join(tmpdir(), 'skillops-apply-'));
|
|
const skillsDir = join(root, 'skills');
|
|
const agentsDir = join(root, 'agents');
|
|
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);
|
|
};
|
|
mkRef('skill-a', 'cat1', 'live.md', '# Live\n');
|
|
mkRef('skill-a', 'cat1', 'dead.md', '# Dead\n');
|
|
mkSkillMd('skill-a', '# skill-a\nSee references/cat1/live.md for details.\n');
|
|
mkRef('skill-b', 'cat3', 'b1.md', '# B1\n');
|
|
mkSkillMd('skill-b', '# skill-b\nSee references/cat3/b1.md.\n');
|
|
mkdirSync(agentsDir, { recursive: true });
|
|
writeFileSync(join(agentsDir, 'agent.md'), 'Reads references/cat1/live.md and references/cat3/b1.md\n');
|
|
mkdirSync(dataDir, { recursive: true });
|
|
try {
|
|
return fn({ root, skillsDir, agentsDir, dataDir });
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// Seed an operator-approved entry by running the planner once (snapshotting its
|
|
// guardrail) and recording it with status:approved — exactly what the operator
|
|
// gate would have written after reviewing the dry-run.
|
|
function approveSanitize(skill, { skillsDir, agentsDir, dataDir }) {
|
|
const { entry } = runSanitizePlan(skill, { skillsDir, agentsDir, dataDir, write: false, decided_at: '2026-06-20' });
|
|
const approved = { ...entry, status: 'approved' };
|
|
saveDecisions(recordAction(loadDecisions(dataDir), approved), dataDir);
|
|
return approved;
|
|
}
|
|
function approveRetire(skill, { skillsDir, dataDir }) {
|
|
const { entry } = runRetirePlan(skill, { skillsDir, dataDir, write: false, decided_at: '2026-06-20' });
|
|
const approved = { ...entry, status: 'approved' };
|
|
saveDecisions(recordAction(loadDecisions(dataDir), approved), dataDir);
|
|
return approved;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// (A) Pure core — revalidateApply
|
|
// ===========================================================================
|
|
|
|
// A self-consistent approved/fresh pair: same op, same guardrail snapshot, ok.
|
|
function pair(overridesApproved = {}, overridesFresh = {}) {
|
|
const guardrail = { method: 'm', preCount: 4, deadCount: 2, removalCount: 2, ok: true };
|
|
const approved = {
|
|
operation_type: SANITIZE_OPERATION,
|
|
status: 'approved',
|
|
targets: { skill: 'skill-x' },
|
|
guardrail,
|
|
...overridesApproved,
|
|
};
|
|
const fresh = {
|
|
entry: { operation_type: SANITIZE_OPERATION, targets: { skill: 'skill-x' } },
|
|
diff: {},
|
|
guardrail: { ...guardrail },
|
|
...overridesFresh,
|
|
};
|
|
return { approved, fresh };
|
|
}
|
|
|
|
test('revalidateApply — OK: approved status, op match, fresh guardrail ok, no drift', () => {
|
|
const { approved, fresh } = pair();
|
|
const r = revalidateApply(approved, fresh);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.drift, false);
|
|
assert.equal(r.freshOk, true);
|
|
assert.equal(r.reason, null);
|
|
});
|
|
|
|
test('revalidateApply — refuses an entry whose status is not approved', () => {
|
|
const { approved, fresh } = pair({ status: 'pending' });
|
|
const r = revalidateApply(approved, fresh);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.reason, /approved/i);
|
|
});
|
|
|
|
test('revalidateApply — refuses on operation_type mismatch', () => {
|
|
const { approved, fresh } = pair({}, { entry: { operation_type: RETIRE_OPERATION, targets: {} } });
|
|
const r = revalidateApply(approved, fresh);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.reason, /operation_type/i);
|
|
});
|
|
|
|
test('revalidateApply — refuses when the FRESH guardrail is not ok', () => {
|
|
const { approved, fresh } = pair({}, { guardrail: { method: 'm', preCount: 4, deadCount: 2, removalCount: 2, ok: false } });
|
|
const r = revalidateApply(approved, fresh);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.freshOk, false);
|
|
});
|
|
|
|
test('revalidateApply — detects DRIFT: fresh guardrail differs from approved snapshot', () => {
|
|
// Disk changed since approval: one dead file already gone -> counts differ.
|
|
const { approved, fresh } = pair({}, { guardrail: { method: 'm', preCount: 3, deadCount: 1, removalCount: 1, ok: true } });
|
|
const r = revalidateApply(approved, fresh);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.drift, true);
|
|
assert.match(r.reason, /drift/i);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (B) Impure executor — sanitize apply (archive-then-remove)
|
|
// ===========================================================================
|
|
|
|
test('applyApprovedAction sanitize — archives the dead ref, removes it, leaves curated untouched', () => {
|
|
withFixture(({ root, skillsDir, agentsDir, dataDir }) => {
|
|
const approved = approveSanitize('skill-a', { skillsDir, agentsDir, dataDir });
|
|
const res = applyApprovedAction(approved, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: true, decided_at: '2026-06-21' });
|
|
|
|
assert.equal(res.applied, true);
|
|
assert.equal(res.revalidate.ok, true);
|
|
|
|
// Dead ref archived FIRST, then gone from skills/.
|
|
assert.ok(existsSync(join(root, 'archive/skills/skill-a/references/cat1/dead.md')), 'dead ref archived');
|
|
assert.equal(existsSync(join(skillsDir, 'skill-a/references/cat1/dead.md')), false, 'dead ref removed from skills/');
|
|
|
|
// Curated content untouched.
|
|
assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/live.md')), 'curated ref preserved');
|
|
assert.ok(existsSync(join(skillsDir, 'skill-a/SKILL.md')), 'SKILL.md preserved');
|
|
|
|
// Ledger flipped approved -> applied.
|
|
assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'applied');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (C) Impure executor — retire apply (whole-skill archive, then dir removal)
|
|
// ===========================================================================
|
|
|
|
test('applyApprovedAction retire — archives the whole skill, then removes its dir; siblings untouched', () => {
|
|
withFixture(({ root, skillsDir, dataDir }) => {
|
|
const approved = approveRetire('skill-b', { skillsDir, dataDir });
|
|
const res = applyApprovedAction(approved, { pluginRoot: root, skillsDir, dataDir, apply: true, decided_at: '2026-06-21' });
|
|
|
|
assert.equal(res.applied, true);
|
|
|
|
// Entire skill archived (SKILL.md + ref) BEFORE the dir is removed.
|
|
assert.ok(existsSync(join(root, 'archive/skills/skill-b/SKILL.md')), 'SKILL.md archived');
|
|
assert.ok(existsSync(join(root, 'archive/skills/skill-b/references/cat3/b1.md')), 'ref archived');
|
|
|
|
// skills/skill-b is gone entirely.
|
|
assert.equal(existsSync(join(skillsDir, 'skill-b')), false, 'retired skill dir removed');
|
|
|
|
// Sibling skill untouched.
|
|
assert.ok(existsSync(join(skillsDir, 'skill-a/SKILL.md')), 'sibling skill survives');
|
|
|
|
assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'applied');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (D) Safety — drift, preview, non-approved, guardrail-fail, unsupported op
|
|
// ===========================================================================
|
|
|
|
test('applyApprovedAction — DRIFT after approval aborts with ZERO mutation and an untouched ledger', () => {
|
|
withFixture(({ root, skillsDir, agentsDir, dataDir }) => {
|
|
const approved = approveSanitize('skill-a', { skillsDir, agentsDir, dataDir });
|
|
// Operator approved when there was 1 dead file. Now the disk drifts: the
|
|
// dead file is already gone -> a fresh plan finds 0 dead -> guardrail differs.
|
|
rmSync(join(skillsDir, 'skill-a/references/cat1/dead.md'));
|
|
|
|
const res = applyApprovedAction(approved, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: true });
|
|
|
|
assert.equal(res.applied, false, 'drift must abort the apply');
|
|
assert.equal(res.revalidate.drift, true);
|
|
assert.equal(existsSync(join(root, 'archive')), false, 'nothing archived on a drift abort');
|
|
assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved', 'ledger left at approved');
|
|
});
|
|
});
|
|
|
|
test('applyApprovedAction — preview (apply:false) mutates nothing and leaves the ledger at approved', () => {
|
|
withFixture(({ root, skillsDir, agentsDir, dataDir }) => {
|
|
const approved = approveSanitize('skill-a', { skillsDir, agentsDir, dataDir });
|
|
const res = applyApprovedAction(approved, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: 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-a/references/cat1/dead.md')), 'dead ref still present (preview only)');
|
|
assert.equal(existsSync(join(root, 'archive')), false, 'preview archives nothing');
|
|
assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved');
|
|
});
|
|
});
|
|
|
|
test('applyApprovedAction — refuses a non-approved (pending) entry with zero mutation', () => {
|
|
withFixture(({ root, skillsDir, agentsDir, dataDir }) => {
|
|
const { entry } = runSanitizePlan('skill-a', { skillsDir, agentsDir, dataDir, write: false });
|
|
// pending, not approved — operator never signed off.
|
|
saveDecisions(recordAction(loadDecisions(dataDir), entry), dataDir);
|
|
|
|
const res = applyApprovedAction(entry, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: true });
|
|
assert.equal(res.applied, false);
|
|
assert.match(res.revalidate.reason, /approved/i);
|
|
assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/dead.md')), 'no mutation');
|
|
assert.equal(existsSync(join(root, 'archive')), false);
|
|
});
|
|
});
|
|
|
|
test('applyApprovedAction — a guardrail-failing approved entry never applies', () => {
|
|
withFixture(({ root, skillsDir, agentsDir, dataDir }) => {
|
|
// Construct an approved entry whose guardrail is NOT ok (illegal removal of a
|
|
// curated ref). A fresh plan with the same removals also fails the guardrail.
|
|
const { entry } = planSanitizeSkill('skill-a', {
|
|
refs: ['cat1/live.md', 'cat1/dead.md'],
|
|
orphans: ['cat1/dead.md'],
|
|
removals: ['cat1/live.md'], // curated -> illegal
|
|
});
|
|
assert.equal(entry.guardrail.ok, false, 'precondition: this dry-run already failed the guardrail');
|
|
const approved = { ...entry, status: 'approved' };
|
|
saveDecisions(recordAction(loadDecisions(dataDir), approved), dataDir);
|
|
|
|
const res = applyApprovedAction(approved, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: true });
|
|
assert.equal(res.applied, false, 'a failing guardrail must never reach the filesystem');
|
|
assert.equal(existsSync(join(root, 'archive')), false);
|
|
assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/live.md')), 'curated ref still safe');
|
|
});
|
|
});
|