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); }); });