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>
This commit is contained in:
parent
f9862b8a6d
commit
f93830ce74
3 changed files with 468 additions and 0 deletions
16
CLAUDE.md
16
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
|
||||
|
|
|
|||
222
scanners/lib/campaign-ledger.mjs
Normal file
222
scanners/lib/campaign-ledger.mjs
Normal file
|
|
@ -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<object|null>}
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
230
tests/lib/campaign-ledger.test.mjs
Normal file
230
tests/lib/campaign-ledger.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue