feat(ms-ai-architect): Sesjon 3 - decision-ledger (lag 2) + discovery-dedup
Lukker discovery-løkken gjennom operatør-gaten. data/decisions.json er eneste skrive-autoriserte bro mellom deteksjon og KB/registry; discovery LESER den og utelater alt operatøren har tatt stilling til. Dedup-policy = A (operatør-valg): isDecided = enhver ledger-entry (approved/ rejected/pending). Kun helt fraværende URLer re-foreslås. - lib/decisions-io.mjs: createLedger/load/save(atomisk)/isDecided/recordDecision (ren)/filterUndecided. TDD: 10 tester før kode. - discover-new-urls.mjs leser ledger, filtrerer, rapporterer deduped_by_ledger. Importerer ALDRI write-utils (invariant verifisert, 6 guard-tester). - Gate dokumentert i kb-update.md §3b (eneste skrivevei). - decisions.json tracket via gitignore-negasjon (som domain-taxonomy.json). Kriterium møtt: dedup-diff (rejected re-foreslås ikke runde 2). Tester: validate 239 · kb-update 82 (+16) · kb-eval 13 · kb-integrity 115/115. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
This commit is contained in:
parent
444b9a375a
commit
fe484ec323
7 changed files with 323 additions and 2 deletions
148
tests/kb-update/test-decisions-io.test.mjs
Normal file
148
tests/kb-update/test-decisions-io.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// tests/kb-update/test-decisions-io.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/decisions-io.mjs — the lag-2 decision
|
||||
// ledger (data/decisions.json), the ONLY write-authorized bridge between
|
||||
// detection and the KB/registry. Dedup policy = A (operatør 2026-06-19):
|
||||
// isDecided(url) is true for ANY ledger entry (pending | approved | rejected),
|
||||
// so discovery re-proposes only URLs entirely absent from the ledger.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createLedger,
|
||||
loadDecisions,
|
||||
saveDecisions,
|
||||
isDecided,
|
||||
recordDecision,
|
||||
filterUndecided,
|
||||
} from '../../scripts/kb-update/lib/decisions-io.mjs';
|
||||
|
||||
function withTmp(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dec-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const U1 = 'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators';
|
||||
const U2 = 'https://learn.microsoft.com/azure/databricks/sql/language-manual/sql-ref-sharing';
|
||||
const U3 = 'https://learn.microsoft.com/azure/ai-services/openai/whats-new';
|
||||
|
||||
// --- scaffold ---
|
||||
|
||||
test('createLedger — empty scaffold shape', () => {
|
||||
const led = createLedger();
|
||||
assert.equal(led.version, 1);
|
||||
assert.equal(led.updated_at, null);
|
||||
assert.deepEqual(led.decisions, {});
|
||||
});
|
||||
|
||||
// --- load / save (atomic) ---
|
||||
|
||||
test('loadDecisions — missing file returns empty scaffold', () => {
|
||||
withTmp((dir) => {
|
||||
const led = loadDecisions(dir);
|
||||
assert.equal(led.version, 1);
|
||||
assert.deepEqual(led.decisions, {});
|
||||
});
|
||||
});
|
||||
|
||||
test('saveDecisions + loadDecisions — round-trips', () => {
|
||||
withTmp((dir) => {
|
||||
let led = createLedger();
|
||||
led = recordDecision(led, U1, {
|
||||
status: 'rejected',
|
||||
decided_at: '2026-06-19',
|
||||
suggested_skill: 'ms-ai-engineering',
|
||||
suggested_category: 'data-engineering',
|
||||
});
|
||||
saveDecisions(led, dir);
|
||||
const back = loadDecisions(dir);
|
||||
assert.deepEqual(back, led);
|
||||
});
|
||||
});
|
||||
|
||||
test('saveDecisions — leaves no .tmp orphan and writes decisions.json', () => {
|
||||
withTmp((dir) => {
|
||||
saveDecisions(createLedger(), dir);
|
||||
const files = readdirSync(dir);
|
||||
assert.ok(existsSync(join(dir, 'decisions.json')));
|
||||
assert.deepEqual(files.filter((f) => f.includes('.tmp')), []);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isDecided — DEDUP POLICY A ---
|
||||
|
||||
test('isDecided — true for ANY entry status (pending | approved | rejected)', () => {
|
||||
let led = createLedger();
|
||||
led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' });
|
||||
led = recordDecision(led, U2, { status: 'approved', decided_at: '2026-06-19' });
|
||||
led = recordDecision(led, U3, { status: 'pending', decided_at: '2026-06-19' });
|
||||
assert.equal(isDecided(led, U1), true, 'rejected must dedup');
|
||||
assert.equal(isDecided(led, U2), true, 'approved must dedup (policy A)');
|
||||
assert.equal(isDecided(led, U3), true, 'pending must dedup (policy A)');
|
||||
});
|
||||
|
||||
test('isDecided — false for URL absent from ledger', () => {
|
||||
const led = createLedger();
|
||||
assert.equal(isDecided(led, U1), false);
|
||||
});
|
||||
|
||||
// --- recordDecision — pure ---
|
||||
|
||||
test('recordDecision — sets entry, returns new ledger, does not mutate input', () => {
|
||||
const led = createLedger();
|
||||
const next = recordDecision(led, U1, {
|
||||
status: 'rejected',
|
||||
decided_at: '2026-06-19',
|
||||
suggested_skill: 'ms-ai-engineering',
|
||||
suggested_category: 'data-engineering',
|
||||
note: 'off-domain databricks page',
|
||||
});
|
||||
assert.equal(next.decisions[U1].status, 'rejected');
|
||||
assert.equal(next.decisions[U1].note, 'off-domain databricks page');
|
||||
// input untouched
|
||||
assert.deepEqual(led.decisions, {});
|
||||
assert.notEqual(next, led);
|
||||
});
|
||||
|
||||
test('recordDecision — advances updated_at to the decision date', () => {
|
||||
const led = createLedger();
|
||||
const next = recordDecision(led, U1, { status: 'approved', decided_at: '2026-06-19' });
|
||||
assert.equal(next.updated_at, '2026-06-19');
|
||||
});
|
||||
|
||||
// --- filterUndecided — the dedup gate discovery applies ---
|
||||
|
||||
test('filterUndecided — drops decided candidates (any status), keeps fresh ones', () => {
|
||||
let led = createLedger();
|
||||
led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' });
|
||||
led = recordDecision(led, U2, { status: 'approved', decided_at: '2026-06-19' });
|
||||
const candidates = [
|
||||
{ url: U1, suggested_skill: 'ms-ai-engineering' }, // rejected → drop
|
||||
{ url: U2, suggested_skill: 'ms-ai-engineering' }, // approved → drop
|
||||
{ url: U3, suggested_skill: 'ms-ai-engineering' }, // fresh → keep
|
||||
];
|
||||
const out = filterUndecided(led, candidates);
|
||||
assert.deepEqual(out.map((c) => c.url), [U3]);
|
||||
});
|
||||
|
||||
// --- ACCEPTANCE CRITERION (roadmap §165): discovery 2x does not re-propose a rejected URL ---
|
||||
|
||||
test('dedup-diff — a rejected URL is re-proposed in round 1 but NOT in round 2', () => {
|
||||
// Round 1: empty ledger — the URL is a fresh candidate, passes the filter.
|
||||
let led = createLedger();
|
||||
const round1 = filterUndecided(led, [{ url: U1, suggested_skill: 'ms-ai-engineering' }]);
|
||||
assert.deepEqual(round1.map((c) => c.url), [U1], 'round 1 should surface the new URL');
|
||||
|
||||
// Operatør rejects it through the gate — the ONLY write path.
|
||||
led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' });
|
||||
|
||||
// Round 2: same discovery output, now the rejected URL must be filtered out.
|
||||
const round2 = filterUndecided(led, [{ url: U1, suggested_skill: 'ms-ai-engineering' }]);
|
||||
assert.deepEqual(round2, [], 'round 2 must NOT re-propose the rejected URL');
|
||||
});
|
||||
44
tests/kb-update/test-discover-invariant.test.mjs
Normal file
44
tests/kb-update/test-discover-invariant.test.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// tests/kb-update/test-discover-invariant.test.mjs
|
||||
// Architecture-invariant guard (STATE.md §83 / roadmap §159): the detection
|
||||
// script discover-new-urls.mjs must NEVER import write-utils. It only READS the
|
||||
// decision ledger to filter; all writes go through the operator gate. saveReport
|
||||
// (its own discovery-report.json) is allowed — that is detection's own output,
|
||||
// not a KB/registry mutation.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DISCOVER = join(__dirname, '..', '..', 'scripts', 'kb-update', 'discover-new-urls.mjs');
|
||||
const src = readFileSync(DISCOVER, 'utf8');
|
||||
|
||||
// Sjekkene er IMPORT-spesifikke med vilje: en invariant-guard som brytes av en
|
||||
// kommentar som *beskriver* invarianten er verdiløs. Vi gransker import-linjer.
|
||||
const importLines = src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n');
|
||||
|
||||
test('discover does NOT import the ledger write path (saveDecisions)', () => {
|
||||
assert.doesNotMatch(importLines, /\bsaveDecisions\b/);
|
||||
});
|
||||
|
||||
test('discover does NOT import the registry write path (saveRegistry)', () => {
|
||||
assert.doesNotMatch(importLines, /\bsaveRegistry\b/);
|
||||
});
|
||||
|
||||
test('discover does NOT import atomic-write', () => {
|
||||
assert.doesNotMatch(importLines, /from\s+'[^']*atomic-write[^']*'/);
|
||||
});
|
||||
|
||||
test('discover does NOT import backup', () => {
|
||||
assert.doesNotMatch(importLines, /from\s+'[^']*\/backup\.mjs'/);
|
||||
});
|
||||
|
||||
test('discover DOES read the ledger (loadDecisions from decisions-io)', () => {
|
||||
assert.match(src, /import \{[^}]*\bloadDecisions\b[^}]*\} from '\.\/lib\/decisions-io\.mjs'/);
|
||||
});
|
||||
|
||||
test('discover actually applies the dedup filter (isDecided)', () => {
|
||||
assert.match(src, /isDecided\(/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue