config-audit/tests/lib/campaign-ledger.test.mjs
Kjell Tore Guttormsen f93830ce74 feat(campaign): durable machine-wide campaign-ledger core (v5.7 Fase 2 Block 3a THIN)
Add the durable ledger that sits ABOVE individual config-audit sessions for a
machine-wide audit campaign: repo list + per-repo lifecycle (pending → audited →
planned → implemented) + a machine-wide roll-up by status and severity.

scanners/lib/campaign-ledger.mjs — same hybrid split as knowledge-refresh:
- PURE transforms (createLedger / addRepo / setRepoStatus / rollUp) with `now`
  injected (YYYY-MM-DD, never the clock) → deterministic + unit-testable.
- soft validateLedger (returns {valid,errors}, never throws) for loaded data;
  transforms throw on programmer error (invalid status, unknown path).
- thin IO shell (defaultLedgerPath / loadLedger→null-on-ENOENT / saveLedger).
- persists to ~/.claude/config-audit/campaign-ledger.json — OUTSIDE the plugin
  dir (next to sessions/) so it survives uninstall/reinstall/upgrade.
- schemaVersion stamped from the start → cheap Block 4 migration.

THIN scope (Block 3a, operator-approved): ledger + roll-up + persistence only —
no CLI/command/execution (Blocks 3b/3c/4). Internal plumbing, byte-stable until
consumed: no `export async function scan` + lives in lib/ → scanner count stays
15, no orchestrator wiring, SC-5 unchanged.

tests/lib/campaign-ledger.test.mjs — 28 tests (TDD, red→green). Full hermetic
suite 1091→1119 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:30:44 +02:00

230 lines
8.2 KiB
JavaScript

import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import {
CAMPAIGN_SCHEMA_VERSION,
STATUSES,
createLedger,
addRepo,
setRepoStatus,
rollUp,
validateLedger,
defaultLedgerPath,
loadLedger,
saveLedger,
} from '../../scanners/lib/campaign-ledger.mjs';
const NOW = '2026-06-22';
const LATER = '2026-06-23';
describe('constants', () => {
it('schema version is 1', () => {
assert.equal(CAMPAIGN_SCHEMA_VERSION, 1);
});
it('STATUSES is the frozen four-step lifecycle', () => {
assert.deepEqual([...STATUSES], ['pending', 'audited', 'planned', 'implemented']);
assert.ok(Object.isFrozen(STATUSES));
});
});
describe('createLedger', () => {
it('builds an empty, versioned ledger stamped with `now`', () => {
const l = createLedger({ now: NOW });
assert.deepEqual(l, {
schemaVersion: 1,
createdDate: NOW,
updatedDate: NOW,
repos: [],
});
});
it('throws on a missing/invalid now', () => {
assert.throws(() => createLedger({}), /now/);
assert.throws(() => createLedger({ now: 'June 22' }), /now/);
});
});
describe('addRepo', () => {
it('adds a pending repo with name, resolved path, and stamps', () => {
const l = addRepo(createLedger({ now: NOW }), { path: '/Users/ktg/repos/foo', name: 'foo' }, { now: NOW });
assert.equal(l.repos.length, 1);
assert.deepEqual(l.repos[0], {
path: '/Users/ktg/repos/foo',
name: 'foo',
status: 'pending',
sessionId: null,
findingsBySeverity: null,
updatedDate: NOW,
});
});
it('normalizes the path (trailing slash, .. segments)', () => {
const l = addRepo(createLedger({ now: NOW }), { path: '/Users/ktg/repos/foo/../foo/', name: 'foo' }, { now: NOW });
assert.equal(l.repos[0].path, '/Users/ktg/repos/foo');
});
it('is idempotent on path — no duplicate, status preserved', () => {
let l = addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW });
l = setRepoStatus(l, '/r/foo', 'audited', { now: NOW });
l = addRepo(l, { path: '/r/foo', name: 'foo' }, { now: LATER });
assert.equal(l.repos.length, 1);
assert.equal(l.repos[0].status, 'audited'); // progress not reset
});
it('bumps ledger.updatedDate but not createdDate', () => {
const base = createLedger({ now: NOW });
const l = addRepo(base, { path: '/r/foo', name: 'foo' }, { now: LATER });
assert.equal(l.createdDate, NOW);
assert.equal(l.updatedDate, LATER);
});
it('does not mutate the input ledger', () => {
const base = createLedger({ now: NOW });
addRepo(base, { path: '/r/foo', name: 'foo' }, { now: NOW });
assert.equal(base.repos.length, 0);
});
it('throws on a missing path', () => {
assert.throws(() => addRepo(createLedger({ now: NOW }), { name: 'foo' }, { now: NOW }), /path/);
});
});
describe('setRepoStatus', () => {
const seed = () => addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW });
it('updates status and stamps the repo + ledger', () => {
const l = setRepoStatus(seed(), '/r/foo', 'audited', { now: LATER });
assert.equal(l.repos[0].status, 'audited');
assert.equal(l.repos[0].updatedDate, LATER);
assert.equal(l.updatedDate, LATER);
});
it('attaches findingsBySeverity and sessionId when provided', () => {
const findings = { critical: 0, high: 2, medium: 5, low: 3 };
const l = setRepoStatus(seed(), '/r/foo', 'audited', { now: NOW, findingsBySeverity: findings, sessionId: '20260404_162210' });
assert.deepEqual(l.repos[0].findingsBySeverity, findings);
assert.equal(l.repos[0].sessionId, '20260404_162210');
});
it('resolves the path the same way addRepo does', () => {
const l = setRepoStatus(seed(), '/r/foo/', 'planned', { now: NOW });
assert.equal(l.repos[0].status, 'planned');
});
it('throws on an invalid status', () => {
assert.throws(() => setRepoStatus(seed(), '/r/foo', 'done', { now: NOW }), /status/);
});
it('throws on an unknown path', () => {
assert.throws(() => setRepoStatus(seed(), '/r/bar', 'audited', { now: NOW }), /not in the ledger|unknown/i);
});
it('does not mutate the input ledger', () => {
const l = seed();
setRepoStatus(l, '/r/foo', 'audited', { now: NOW });
assert.equal(l.repos[0].status, 'pending');
});
});
describe('rollUp', () => {
it('returns all-zero buckets for an empty ledger', () => {
const r = rollUp(createLedger({ now: NOW }));
assert.equal(r.totalRepos, 0);
assert.deepEqual(r.byStatus, { pending: 0, audited: 0, planned: 0, implemented: 0 });
assert.deepEqual(r.bySeverity, { critical: 0, high: 0, medium: 0, low: 0 });
assert.equal(r.reposWithFindings, 0);
});
it('counts statuses and aggregates severity machine-wide', () => {
let l = createLedger({ now: NOW });
l = addRepo(l, { path: '/r/a', name: 'a' }, { now: NOW });
l = addRepo(l, { path: '/r/b', name: 'b' }, { now: NOW });
l = addRepo(l, { path: '/r/c', name: 'c' }, { now: NOW });
l = setRepoStatus(l, '/r/a', 'audited', { now: NOW, findingsBySeverity: { critical: 1, high: 2, medium: 0, low: 4 } });
l = setRepoStatus(l, '/r/b', 'implemented', { now: NOW, findingsBySeverity: { critical: 0, high: 1, medium: 3, low: 0 } });
// c stays pending, no findings
const r = rollUp(l);
assert.equal(r.totalRepos, 3);
assert.deepEqual(r.byStatus, { pending: 1, audited: 1, planned: 0, implemented: 1 });
assert.deepEqual(r.bySeverity, { critical: 1, high: 3, medium: 3, low: 4 });
assert.equal(r.reposWithFindings, 2);
});
});
describe('validateLedger', () => {
const good = () => setRepoStatus(
addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW }),
'/r/foo', 'audited', { now: NOW, findingsBySeverity: { critical: 0, high: 0, medium: 1, low: 0 } },
);
it('accepts a well-formed ledger', () => {
const res = validateLedger(good());
assert.equal(res.valid, true, res.errors.join('; '));
assert.deepEqual(res.errors, []);
});
it('rejects a non-object', () => {
assert.equal(validateLedger(null).valid, false);
assert.equal(validateLedger([]).valid, false);
});
it('rejects a bad schemaVersion', () => {
const l = { ...good(), schemaVersion: '1' };
assert.equal(validateLedger(l).valid, false);
});
it('rejects repos that is not an array', () => {
const l = { ...good(), repos: {} };
assert.equal(validateLedger(l).valid, false);
});
it('rejects a repo missing a path', () => {
const l = good();
l.repos = [{ ...l.repos[0], path: undefined }];
assert.equal(validateLedger(l).valid, false);
});
it('rejects a repo with an out-of-enum status', () => {
const l = good();
l.repos = [{ ...l.repos[0], status: 'done' }];
assert.equal(validateLedger(l).valid, false);
});
it('rejects duplicate repo paths', () => {
const l = good();
l.repos = [l.repos[0], { ...l.repos[0] }];
assert.equal(validateLedger(l).valid, false);
});
});
describe('persistence (IO)', () => {
it('defaultLedgerPath lives under ~/.claude/config-audit (survives uninstall)', () => {
const p = defaultLedgerPath();
assert.equal(p, join(homedir(), '.claude', 'config-audit', 'campaign-ledger.json'));
});
it('round-trips through save/load and creates parent dirs', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ca-ledger-'));
after(() => rm(dir, { recursive: true, force: true }));
const file = join(dir, 'nested', 'campaign-ledger.json');
const l = good();
function good() {
return addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW });
}
await saveLedger(file, l);
const loaded = await loadLedger(file);
assert.deepEqual(loaded, l);
// human-readable JSON on disk
const raw = await readFile(file, 'utf8');
assert.match(raw, /\n/);
});
it('loadLedger returns null for a missing file (graceful first run)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ca-ledger-'));
after(() => rm(dir, { recursive: true, force: true }));
const loaded = await loadLedger(join(dir, 'nope.json'));
assert.equal(loaded, null);
});
});