ms-ai-architect/tests/kb-update/test-decisions-actions.test.mjs
Kjell Tore Guttormsen f03a0e08b2 feat(ms-ai-architect): Sesjon 16 — B3 ledger action-lag + merge_skills dry-run-planner
Spor B / B3 start. Operatør-gated dry-run for skill-livssyklus, merge først.

- decisions-io.mjs: additivt skill-nøklet `actions`-lag ved siden av urørt
  URL-nøklet `decisions` (14 URL-tester urørt = regresjonsbevis). version=1,
  loadDecisions backfiller manglende actions-nøkkel. Nye rene fn: actionKey
  (merge symmetrisk på paret), isActionDecided, recordAction (no-mutate),
  listActions. Policy A gjelder også actions.
- scripts/kb-eval/lib/skill-ops.mjs: ren planMergeSkills → {entry,diff,guardrail},
  leser ingen disk, anvender INGENTING. Guardrail = ingen kuratert verdi tapt
  (count-invariant + set-equality + kollisjons-deteksjon; ok=false ved klobring).
  Impur skall loadSkillRefs/runMergePlan + CLI plan-skill-op.mjs (gated --write).
- Ekte eng↔infra dry-run: guardrail OK (34+153=187, 0 kollisjoner), 153 fil-flytt
  + 7 taksonomi-reassign + retire eng; 0 skriving til skills/, ledger uendret
  (retning er operatør/judge-valg, ikke pushet gjennom gaten).
- TDD: test-decisions-actions (10) + test-skill-ops-merge (10, inkl. byte-for-byte
  skills/-snapshot som no-write-bevis). kb-eval 40→50, kb-update 122→132.
  Regresjon grønn: validate 239, kb-integrity 192/192.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:10:33 +02:00

156 lines
5.6 KiB
JavaScript

// tests/kb-update/test-decisions-actions.test.mjs
// Spor B / B3 (Sesjon 16): the ADDITIVE skill-level action layer on the lag-2
// decision ledger. The URL-keyed `decisions` map (Spor A) is untouched — its
// contract is covered by test-decisions-io.test.mjs, which stays green
// unmodified, proving the extension is non-regressive. Here we cover only the
// new `actions` collection: actionKey dedup, recordAction (pure), isActionDecided,
// listActions, and backward-compatible normalization of pre-action ledgers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createLedger,
loadDecisions,
saveDecisions,
recordDecision,
isDecided,
actionKey,
isActionDecided,
recordAction,
listActions,
} from '../../scripts/kb-update/lib/decisions-io.mjs';
function withTmp(fn) {
const dir = mkdtempSync(join(tmpdir(), 'dec-act-test-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const MERGE = {
operation_type: 'merge_skills',
status: 'pending',
decided_at: '2026-06-20',
targets: { absorber: 'ms-ai-infrastructure', absorbed: 'ms-ai-engineering' },
guardrail: { ok: true },
note: 'dry-run',
};
// --- scaffold now carries an empty actions collection (additive) ---
test('createLedger — additive actions:{} alongside untouched decisions:{}', () => {
const led = createLedger();
assert.equal(led.version, 1, 'version stays 1 — no migration needed');
assert.deepEqual(led.decisions, {}, 'URL-keyed map unchanged');
assert.deepEqual(led.actions, {}, 'new skill-op collection present and empty');
});
// --- actionKey — deterministic dedup key ---
test('actionKey — merge_skills key is symmetric on the pair (direction-agnostic dedup)', () => {
const k1 = actionKey(MERGE);
const k2 = actionKey({
...MERGE,
targets: { absorber: 'ms-ai-engineering', absorbed: 'ms-ai-infrastructure' },
});
assert.equal(k1, k2, 'merging A<-B and B<-A must dedup to the same key');
assert.equal(k1, 'merge_skills:ms-ai-engineering+ms-ai-infrastructure');
});
test('actionKey — single-skill ops keyed by their one target', () => {
assert.equal(
actionKey({ operation_type: 'sanitize_skill', targets: { skill: 'ms-ai-governance' } }),
'sanitize_skill:ms-ai-governance',
);
assert.equal(
actionKey({ operation_type: 'create_skill', targets: { name: 'ms-power-platform' } }),
'create_skill:ms-power-platform',
);
});
// --- recordAction — pure, mirrors recordDecision ---
test('recordAction — records under actionKey, returns new ledger, does not mutate input', () => {
const led = createLedger();
const next = recordAction(led, MERGE);
assert.deepEqual(led.actions, {}, 'input untouched');
assert.notEqual(next, led);
assert.equal(next.actions['merge_skills:ms-ai-engineering+ms-ai-infrastructure'].status, 'pending');
});
test('recordAction — advances updated_at to the action date', () => {
const next = recordAction(createLedger(), MERGE);
assert.equal(next.updated_at, '2026-06-20');
});
test('recordAction — does NOT touch the URL-keyed decisions map', () => {
let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', {
status: 'approved',
decided_at: '2026-06-19',
});
const next = recordAction(led, MERGE);
assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives');
assert.equal(isActionDecided(next, actionKey(MERGE)), true, 'action recorded alongside');
});
// --- isActionDecided — dedup gate (policy A: any status counts) ---
test('isActionDecided — true for ANY recorded status, false when absent', () => {
let led = createLedger();
assert.equal(isActionDecided(led, actionKey(MERGE)), false);
led = recordAction(led, { ...MERGE, status: 'rejected' });
assert.equal(isActionDecided(led, actionKey(MERGE)), true, 'rejected merge still dedups (policy A)');
});
// --- listActions — flat list, optional status filter ---
test('listActions — returns all entries, filters by status when given', () => {
let led = createLedger();
led = recordAction(led, MERGE); // pending
led = recordAction(led, {
operation_type: 'sanitize_skill',
status: 'approved',
decided_at: '2026-06-20',
targets: { skill: 'ms-ai-governance' },
});
assert.equal(listActions(led).length, 2);
assert.deepEqual(
listActions(led, 'pending').map((a) => a.operation_type),
['merge_skills'],
);
assert.deepEqual(
listActions(led, 'approved').map((a) => a.operation_type),
['sanitize_skill'],
);
});
// --- backward compatibility: a pre-action ledger on disk loads cleanly ---
test('loadDecisions — normalizes a pre-action (no actions key) ledger to actions:{}', () => {
withTmp((dir) => {
// Simulate an old on-disk ledger written before the action layer existed.
writeFileSync(
join(dir, 'decisions.json'),
JSON.stringify({ version: 1, updated_at: null, decisions: {} }, null, 2),
);
const led = loadDecisions(dir);
assert.deepEqual(led.actions, {}, 'missing actions key is backfilled, not undefined');
// and recording an action on the loaded ledger works
const next = recordAction(led, MERGE);
assert.equal(isActionDecided(next, actionKey(MERGE)), true);
});
});
test('saveDecisions + loadDecisions — actions round-trip through disk', () => {
withTmp((dir) => {
const led = recordAction(createLedger(), MERGE);
saveDecisions(led, dir);
const back = loadDecisions(dir);
assert.deepEqual(back, led);
});
});