Extend the campaign ledger with a machine-wide always-loaded token bill, the
pure data-model + aggregation half of B2. Deliberately does NOT do live I/O:
the cross-repo readActiveConfig sweep that populates real numbers is B2b.
Design — shared layer counted ONCE, structurally:
- Ledger root gets an optional `sharedGlobal` summary (the always-loaded layer
paid in every repo: global CLAUDE.md + agent listing + global MCP + unscoped
global rules + active plugins' always components), stored ONCE via the new
setSharedGlobal().
- Each repo entry gets an optional `tokens` summary (its PER-REPO delta only),
set via the new setRepoTokens(); addRepo now seeds `tokens: null` (mirrors
findingsBySeverity).
- rollUp() stays PURE and gains a `tokens` aggregate: machineWide = sharedGlobal
+ Σ(per-repo deltas), so the shared layer is counted exactly once by
construction — the structural guard against the historic double-count bug.
Plus a `byRepo` table ranked DESC by always-loaded cost ("most expensive
repos") with a deterministic name tie-break.
Summary shape mirrors manifest's summarizeByLoadPattern exactly
({always|onDemand|external|unknown: {tokens,count}}) so B2b wires in trivially.
Keeps rollUp pure (no filesystem I/O) → byte-stable, respects the THIN campaign
motor invariant. Chosen over the plan's literal "run readActiveConfig inside
rollUp" which would break both (operator-approved deviation).
- 8 new ledger tests incl. the counted-once regression guard + byRepo ranking.
- Updated addRepo shape test (+tokens:null) and campaign-cli EMPTY_ROLLUP.
- No frozen snapshot affected (campaign is v5.7, absent from v5.0.0 baselines).
Suite green: 1189 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
429 lines
17 KiB
JavaScript
429 lines
17 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,
|
|
SEVERITY_WEIGHTS,
|
|
createLedger,
|
|
addRepo,
|
|
setRepoStatus,
|
|
setSharedGlobal,
|
|
setRepoTokens,
|
|
rollUp,
|
|
buildBacklog,
|
|
validateLedger,
|
|
defaultLedgerPath,
|
|
loadLedger,
|
|
saveLedger,
|
|
} from '../../scanners/lib/campaign-ledger.mjs';
|
|
|
|
const NOW = '2026-06-22';
|
|
const LATER = '2026-06-23';
|
|
|
|
/**
|
|
* Build a load-pattern summary in the exact shape manifest's `summarizeByLoadPattern`
|
|
* emits (and the shape B2b will store): one {tokens,count} bucket per load pattern.
|
|
*/
|
|
const tokSummary = (always, onDemand = 0, external = 0, unknown = 0) => ({
|
|
always: { tokens: always, count: 1 },
|
|
onDemand: { tokens: onDemand, count: 1 },
|
|
external: { tokens: external, count: 1 },
|
|
unknown: { tokens: unknown, count: 1 },
|
|
});
|
|
|
|
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,
|
|
tokens: 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('machine-wide token roll-up (B2a)', () => {
|
|
it('addRepo seeds a null tokens field (mirrors findingsBySeverity)', () => {
|
|
const l = addRepo(createLedger({ now: NOW }), { path: '/r/a', name: 'a' }, { now: NOW });
|
|
assert.equal(l.repos[0].tokens, null);
|
|
});
|
|
|
|
it('setSharedGlobal stores the shared layer at the ledger root, immutably + stamps', () => {
|
|
const l0 = createLedger({ now: NOW });
|
|
const l1 = setSharedGlobal(l0, tokSummary(1000, 50, 7), { now: LATER });
|
|
assert.deepEqual(l1.sharedGlobal, tokSummary(1000, 50, 7));
|
|
assert.equal(l1.updatedDate, LATER);
|
|
// immutable: original untouched, no sharedGlobal leaked onto it
|
|
assert.equal(l0.sharedGlobal, undefined);
|
|
assert.equal(l0.updatedDate, NOW);
|
|
});
|
|
|
|
it('setRepoTokens attaches per-repo tokens, immutably + stamps', () => {
|
|
let l = addRepo(createLedger({ now: NOW }), { path: '/r/a', name: 'a' }, { now: NOW });
|
|
const before = l;
|
|
l = setRepoTokens(l, '/r/a', tokSummary(120), { now: LATER });
|
|
assert.deepEqual(l.repos[0].tokens, tokSummary(120));
|
|
assert.equal(l.repos[0].updatedDate, LATER);
|
|
assert.equal(l.updatedDate, LATER);
|
|
assert.equal(before.repos[0].tokens, null); // input ledger not mutated
|
|
});
|
|
|
|
it('setRepoTokens throws when the repo is not tracked', () => {
|
|
const l = createLedger({ now: NOW });
|
|
assert.throws(() => setRepoTokens(l, '/r/missing', tokSummary(1), { now: NOW }), /not in the ledger/);
|
|
});
|
|
|
|
it('rollUp returns an all-zero token aggregate for a fresh ledger', () => {
|
|
const r = rollUp(createLedger({ now: NOW }));
|
|
assert.deepEqual(r.tokens, {
|
|
sharedGlobal: { always: 0, onDemand: 0, external: 0, unknown: 0 },
|
|
perRepoDelta: { always: 0, onDemand: 0, external: 0, unknown: 0 },
|
|
machineWide: { always: 0, onDemand: 0, external: 0, unknown: 0 },
|
|
reposWithTokens: 0,
|
|
byRepo: [],
|
|
});
|
|
});
|
|
|
|
it('counts the shared-global layer ONCE, not per repo (double-count regression guard)', () => {
|
|
let l = createLedger({ now: NOW });
|
|
l = setSharedGlobal(l, tokSummary(1000), { now: NOW }); // shared always-loaded = 1000
|
|
for (const name of ['a', 'b', 'c']) {
|
|
l = addRepo(l, { path: `/r/${name}`, name }, { now: NOW });
|
|
l = setRepoTokens(l, `/r/${name}`, tokSummary(100), { now: NOW }); // each per-repo delta = 100
|
|
}
|
|
const t = rollUp(l).tokens;
|
|
assert.equal(t.sharedGlobal.always, 1000, 'shared layer stored once');
|
|
assert.equal(t.perRepoDelta.always, 300, 'sum of three per-repo deltas');
|
|
// THE invariant: shared layer counted ONCE (1000 + 300), NOT once per repo (3*1000 + 300)
|
|
assert.equal(t.machineWide.always, 1300);
|
|
assert.equal(t.reposWithTokens, 3);
|
|
});
|
|
|
|
it('ranks byRepo DESC by always-tokens with a deterministic name tie-break', () => {
|
|
let l = createLedger({ now: NOW });
|
|
// deltas out of order + a tie at 100 to exercise the name tie-break
|
|
const spec = [['a', 100], ['z', 300], ['m', 100], ['q', 50]];
|
|
for (const [name, always] of spec) {
|
|
l = addRepo(l, { path: `/r/${name}`, name }, { now: NOW });
|
|
l = setRepoTokens(l, `/r/${name}`, tokSummary(always), { now: NOW });
|
|
}
|
|
const byRepo = rollUp(l).tokens.byRepo;
|
|
assert.deepEqual(byRepo.map((r) => r.name), ['z', 'a', 'm', 'q']);
|
|
assert.deepEqual(byRepo[0], { name: 'z', path: '/r/z', always: 300, onDemand: 0, external: 0 });
|
|
});
|
|
|
|
it('tolerates old ledgers: no sharedGlobal + repos without tokens → all zero', () => {
|
|
let l = createLedger({ now: NOW });
|
|
l = addRepo(l, { path: '/r/a', name: 'a' }, { now: NOW }); // no tokens set
|
|
const t = rollUp(l).tokens;
|
|
assert.equal(t.machineWide.always, 0);
|
|
assert.equal(t.reposWithTokens, 0);
|
|
assert.deepEqual(t.byRepo, []);
|
|
});
|
|
});
|
|
|
|
describe('buildBacklog', () => {
|
|
// Seed a ledger from a compact spec. A repo with `status` (and optional `findings`) is
|
|
// transitioned; a bare repo stays `pending` with null findings.
|
|
function ledgerWith(specs) {
|
|
let l = createLedger({ now: NOW });
|
|
for (const s of specs) {
|
|
l = addRepo(l, { path: s.path, name: s.name }, { now: NOW });
|
|
if (s.status) {
|
|
l = setRepoStatus(l, s.path, s.status, {
|
|
now: NOW,
|
|
findingsBySeverity: s.findings,
|
|
sessionId: s.sessionId,
|
|
});
|
|
}
|
|
}
|
|
return l;
|
|
}
|
|
|
|
it('SEVERITY_WEIGHTS is the frozen order-of-magnitude weighting', () => {
|
|
assert.deepEqual({ ...SEVERITY_WEIGHTS }, { critical: 1000, high: 100, medium: 10, low: 1 });
|
|
assert.ok(Object.isFrozen(SEVERITY_WEIGHTS));
|
|
});
|
|
|
|
it('returns [] for an empty ledger', () => {
|
|
assert.deepEqual(buildBacklog(createLedger({ now: NOW })), []);
|
|
});
|
|
|
|
it('excludes implemented, pending, and zero-finding repos', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/done', name: 'done', status: 'implemented', findings: { critical: 5, high: 0, medium: 0, low: 0 } },
|
|
{ path: '/r/pend', name: 'pend' }, // pending, no findings
|
|
{ path: '/r/clean', name: 'clean', status: 'audited', findings: { critical: 0, high: 0, medium: 0, low: 0 } },
|
|
]);
|
|
assert.deepEqual(buildBacklog(l), []);
|
|
});
|
|
|
|
it('includes audited and planned repos that still have findings', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
|
|
{ path: '/r/p', name: 'p', status: 'planned', findings: { critical: 0, high: 0, medium: 2, low: 0 } },
|
|
]);
|
|
const names = buildBacklog(l).map((i) => i.name).sort();
|
|
assert.deepEqual(names, ['a', 'p']);
|
|
});
|
|
|
|
it('ranks by severity (critical outranks high outranks low) and assigns rank 1..n', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/low', name: 'low', status: 'audited', findings: { critical: 0, high: 0, medium: 0, low: 9 } },
|
|
{ path: '/r/crit', name: 'crit', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
|
|
{ path: '/r/high', name: 'high', status: 'planned', findings: { critical: 0, high: 5, medium: 0, low: 0 } },
|
|
]);
|
|
const backlog = buildBacklog(l);
|
|
assert.deepEqual(backlog.map((i) => i.name), ['crit', 'high', 'low']);
|
|
assert.deepEqual(backlog.map((i) => i.rank), [1, 2, 3]);
|
|
});
|
|
|
|
it('computes weightedScore and totalFindings, normalizing missing severity keys to 0', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/x', name: 'x', status: 'audited', findings: { high: 2 } }, // critical/medium/low missing
|
|
]);
|
|
const [item] = buildBacklog(l);
|
|
assert.deepEqual(item.findingsBySeverity, { critical: 0, high: 2, medium: 0, low: 0 });
|
|
assert.equal(item.totalFindings, 2);
|
|
assert.equal(item.weightedScore, 200);
|
|
});
|
|
|
|
it('breaks a weightedScore tie by critical count, then by name', () => {
|
|
const l = ledgerWith([
|
|
// both score 1000: 10 high vs 1 critical → critical wins the tie
|
|
{ path: '/r/tenHigh', name: 'tenHigh', status: 'audited', findings: { critical: 0, high: 10, medium: 0, low: 0 } },
|
|
{ path: '/r/oneCrit', name: 'oneCrit', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
|
|
]);
|
|
assert.deepEqual(buildBacklog(l).map((i) => i.name), ['oneCrit', 'tenHigh']);
|
|
|
|
// fully identical findings → final tie-break is name (ascending)
|
|
const l2 = ledgerWith([
|
|
{ path: '/r/zebra', name: 'zebra', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
|
|
{ path: '/r/alpha', name: 'alpha', status: 'audited', findings: { critical: 0, high: 1, medium: 0, low: 0 } },
|
|
]);
|
|
assert.deepEqual(buildBacklog(l2).map((i) => i.name), ['alpha', 'zebra']);
|
|
});
|
|
|
|
it('carries per-repo provenance on each item', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 }, sessionId: '20260622_120000' },
|
|
]);
|
|
const [item] = buildBacklog(l);
|
|
assert.equal(item.path, '/r/a');
|
|
assert.equal(item.name, 'a');
|
|
assert.equal(item.status, 'audited');
|
|
assert.equal(item.sessionId, '20260622_120000');
|
|
});
|
|
|
|
it('does not mutate the input ledger', () => {
|
|
const l = ledgerWith([
|
|
{ path: '/r/a', name: 'a', status: 'audited', findings: { critical: 1, high: 0, medium: 0, low: 0 } },
|
|
]);
|
|
const snapshot = JSON.stringify(l);
|
|
buildBacklog(l);
|
|
assert.equal(JSON.stringify(l), snapshot);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|