diff --git a/CLAUDE.md b/CLAUDE.md index 37eb44e..8b270d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -419,6 +419,22 @@ Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core like `/config-audit optimize`. **No new agent** (web poll runs in the command's own context), **no new orchestrated scanner**. suite 1068→1091. +### campaign-ledger — durable machine-wide campaign core (v5.7 Fase 2, Block 3a THIN) + +`scanners/lib/campaign-ledger.mjs`: the durable ledger that sits ABOVE individual sessions for a +machine-wide audit campaign — repo list + per-repo lifecycle (`STATUSES` = pending→audited→planned +→implemented) + a machine-wide `rollUp` (counts by status + severity aggregated across repos). It +persists to a single JSON file **outside** the plugin dir (`~/.claude/config-audit/campaign-ledger +.json`, next to `sessions/`) so it survives uninstall/reinstall/upgrade. Same hybrid split as +knowledge-refresh: PURE transforms (`createLedger`/`addRepo`/`setRepoStatus`/`rollUp`) with `now` +**injected** (YYYY-MM-DD, never the clock) + soft `validateLedger` (returns `{valid,errors}`, never +throws) + a thin IO shell (`defaultLedgerPath`/`loadLedger`→null-on-ENOENT/`saveLedger`). Transforms +throw on programmer error (invalid status, unknown path); `schemaVersion` stamped from the start so a +Block 4 migration is cheap. **THIN**: ledger + roll-up + persistence only — NO execution, CLI, or +command surface (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. +28 tests, suite 1091→1119. + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/scanners/lib/campaign-ledger.mjs b/scanners/lib/campaign-ledger.mjs new file mode 100644 index 0000000..e8a47c9 --- /dev/null +++ b/scanners/lib/campaign-ledger.mjs @@ -0,0 +1,222 @@ +/** + * campaign-ledger — durable, machine-wide campaign ledger (v5.7 Fase 2, Block 3a THIN). + * + * The ledger sits ABOVE individual config-audit sessions: it tracks which repos are part + * of a machine-wide audit campaign, each repo's lifecycle status (pending → audited → + * planned → implemented), and a machine-wide roll-up by status + severity. It is resumable + * across sessions because it persists to a single JSON file OUTSIDE the plugin dir + * (`~/.claude/config-audit/campaign-ledger.json`, next to `sessions/` and `mcp-cache/`), + * so it survives plugin uninstall/reinstall/upgrade. + * + * Design mirrors the knowledge-refresh precedent: the transformations are PURE and + * deterministic (every "now" is injected as a YYYY-MM-DD string, never read from the clock + * here), so they are fully unit-testable; a thin IO shell (load/save, explicit path) does + * the only filesystem work. `validateLedger` is soft (returns a result, never throws) for + * externally-loaded data; the transforms throw on programmer error (invalid status, unknown + * path). `schemaVersion` is stamped from the start so a future Block 4 migration is cheap. + * + * THIN scope (Block 3a): ledger core + roll-up + persistence only — NOT execution, + * orchestration, or a command surface (those are Blocks 3b/3c/4). Zero dependencies. + * See docs/v5.7-optimization-lens-plan.md §Fase 2. + */ + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; + +/** Ledger schema version — bump + add a migration (Block 4) on any breaking shape change. */ +export const CAMPAIGN_SCHEMA_VERSION = 1; + +/** Per-repo lifecycle, in order. A repo advances through these as the campaign progresses. */ +export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'implemented']); + +/** Severity buckets aggregated by the machine-wide roll-up. */ +const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */ +function requireNow(now) { + if (typeof now !== 'string' || !DATE_RE.test(now)) { + throw new TypeError('now must be a YYYY-MM-DD string'); + } + return now; +} + +/** Canonicalize a repo path so the same repo never appears twice under different spellings. */ +function normalizePath(path) { + if (typeof path !== 'string' || path.trim() === '') { + throw new TypeError('repo path is required'); + } + return resolve(path); +} + +/** + * Build an empty, versioned ledger stamped with `now`. + * @param {{now: string}} opts + * @returns {{schemaVersion:number, createdDate:string, updatedDate:string, repos:object[]}} + */ +export function createLedger({ now } = {}) { + requireNow(now); + return { schemaVersion: CAMPAIGN_SCHEMA_VERSION, createdDate: now, updatedDate: now, repos: [] }; +} + +/** + * Add a repo to the campaign (status `pending`). Idempotent on the normalized path — a repo + * already present is left untouched (its progress is NOT reset). Returns a NEW ledger. + * @param {object} ledger + * @param {{path: string, name?: string}} repo + * @param {{now: string}} opts + */ +export function addRepo(ledger, { path, name } = {}, { now } = {}) { + requireNow(now); + const resolved = normalizePath(path); + if (ledger.repos.some((r) => r.path === resolved)) { + return ledger; // idempotent: already tracked, preserve its status + } + const repo = { + path: resolved, + name: typeof name === 'string' && name.trim() !== '' ? name : resolved.split('/').pop(), + status: 'pending', + sessionId: null, + findingsBySeverity: null, + updatedDate: now, + }; + return { ...ledger, updatedDate: now, repos: [...ledger.repos, repo] }; +} + +/** + * Transition a tracked repo to a new status, optionally attaching the audit's + * findings-by-severity and the producing sessionId. Returns a NEW ledger. + * @param {object} ledger + * @param {string} path + * @param {string} status - one of STATUSES + * @param {{now: string, findingsBySeverity?: object|null, sessionId?: string|null}} opts + */ +export function setRepoStatus(ledger, path, status, { now, findingsBySeverity, sessionId } = {}) { + requireNow(now); + if (!STATUSES.includes(status)) { + throw new RangeError(`invalid status "${status}" — must be one of ${STATUSES.join(', ')}`); + } + const resolved = normalizePath(path); + const idx = ledger.repos.findIndex((r) => r.path === resolved); + if (idx === -1) { + throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`); + } + const updated = { ...ledger.repos[idx], status, updatedDate: now }; + if (findingsBySeverity !== undefined) updated.findingsBySeverity = findingsBySeverity; + if (sessionId !== undefined) updated.sessionId = sessionId; + const repos = ledger.repos.slice(); + repos[idx] = updated; + return { ...ledger, updatedDate: now, repos }; +} + +/** + * Machine-wide roll-up: repo counts by status, plus a severity total aggregated across every + * repo that carries `findingsBySeverity`. Pure derivation — never mutates. + * @param {object} ledger + * @returns {{totalRepos:number, byStatus:object, bySeverity:object, reposWithFindings:number}} + */ +export function rollUp(ledger) { + const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0])); + const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0])); + let reposWithFindings = 0; + + for (const repo of ledger.repos) { + if (byStatus[repo.status] !== undefined) byStatus[repo.status] += 1; + const f = repo.findingsBySeverity; + if (f && typeof f === 'object') { + reposWithFindings += 1; + for (const sev of SEVERITIES) { + if (typeof f[sev] === 'number') bySeverity[sev] += f[sev]; + } + } + } + return { totalRepos: ledger.repos.length, byStatus, bySeverity, reposWithFindings }; +} + +/** + * Validate a parsed ledger against the schema. Never throws — returns every problem at once + * so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt). + * @param {unknown} data + * @returns {{valid:boolean, errors:string[]}} + */ +export function validateLedger(data) { + const errors = []; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { valid: false, errors: ['ledger must be an object'] }; + } + if (data.schemaVersion !== CAMPAIGN_SCHEMA_VERSION) { + errors.push(`schemaVersion must be ${CAMPAIGN_SCHEMA_VERSION}`); + } + for (const field of ['createdDate', 'updatedDate']) { + if (typeof data[field] !== 'string' || !DATE_RE.test(data[field])) { + errors.push(`${field} must be YYYY-MM-DD`); + } + } + if (!Array.isArray(data.repos)) { + errors.push('repos must be an array'); + return { valid: false, errors }; + } + + const seen = new Set(); + data.repos.forEach((r, i) => { + const at = `repos[${i}]${r && typeof r === 'object' && r.path ? ` (${r.path})` : ''}`; + if (!r || typeof r !== 'object' || Array.isArray(r)) { + errors.push(`${at}: must be an object`); + return; + } + if (typeof r.path !== 'string' || r.path.trim() === '') { + errors.push(`${at}: path is required`); + } else if (seen.has(r.path)) { + errors.push(`${at}: duplicate path`); + } else { + seen.add(r.path); + } + if (!STATUSES.includes(r.status)) { + errors.push(`${at}: status must be one of ${STATUSES.join(', ')}`); + } + }); + + return { valid: errors.length === 0, errors }; +} + +// ── Persistence (thin IO shell) ──────────────────────────────────────────────── + +/** + * Default on-disk location: next to `sessions/`, OUTSIDE the plugin dir, so the campaign + * survives plugin uninstall/reinstall/upgrade. + * @returns {string} + */ +export function defaultLedgerPath() { + return join(homedir(), '.claude', 'config-audit', 'campaign-ledger.json'); +} + +/** + * Load + parse a ledger file. Returns `null` if the file does not exist (graceful first run); + * other read/parse errors propagate so corruption is not silently swallowed. + * @param {string} [path] + * @returns {Promise} + */ +export async function loadLedger(path = defaultLedgerPath()) { + let content; + try { + content = await readFile(path, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') return null; + throw err; + } + return JSON.parse(content); +} + +/** + * Persist a ledger as human-readable JSON, creating parent directories as needed. + * @param {string} path + * @param {object} ledger + * @returns {Promise<{path: string}>} + */ +export async function saveLedger(path = defaultLedgerPath(), ledger) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8'); + return { path }; +} diff --git a/tests/lib/campaign-ledger.test.mjs b/tests/lib/campaign-ledger.test.mjs new file mode 100644 index 0000000..c1fe43a --- /dev/null +++ b/tests/lib/campaign-ledger.test.mjs @@ -0,0 +1,230 @@ +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); + }); +});