config-audit/scanners/lib/campaign-ledger.mjs
Kjell Tore Guttormsen 49833aded8 feat(campaign): cross-repo prioritized backlog (v5.7 Fase 2 Block 4b)
buildBacklog(ledger) pure transform + read-only campaign-cli payload field
+ command rendering. The single machine-wide pick-list: per-repo (the ledger
tracks severity counts, not individual findings), severity-weighted
(SEVERITY_WEIGHTS c1000/h100/m10/l1), deterministic tie-break, excludes
implemented/pending/zero-finding repos.

No schema change, no new scanner -> scanner count stays 15, snapshot/backcompat
byte-stable. suite 1138->1150 (lib +9, campaign-cli +3). README badge 1091+->1150+.

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

289 lines
11 KiB
JavaScript

/**
* 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']);
/**
* Order-of-magnitude severity weights for the cross-repo backlog priority score. Each tier
* dominates the next so a single higher-severity finding outranks many lower ones; exact
* score collisions are still broken deterministically by the lexicographic + name tie-break
* in `buildBacklog`. Exported so the score is documented, not a magic number.
*/
export const SEVERITY_WEIGHTS = Object.freeze({ critical: 1000, high: 100, medium: 10, low: 1 });
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 };
}
/**
* Build the single, machine-wide PRIORITIZED backlog the user picks from. Pure derivation —
* never mutates. The actionable unit is a REPO (the ledger tracks per-repo severity counts,
* not individual findings — it tracks state, it does not re-run audits), so each backlog item
* is one repo with outstanding work.
*
* Inclusion: a repo is in the backlog iff it is NOT yet `implemented` AND has at least one
* outstanding finding (`totalFindings > 0`). `implemented` repos are done; `pending` and
* zero-finding repos have nothing known to fix (they still surface in `rollUp.byStatus`).
*
* Order: DESC by `weightedScore` (SEVERITY_WEIGHTS), tie-broken lexicographically by
* critical→high→medium→low count, then ascending by `name` — fully deterministic, and the
* tie-break preserves "criticals always win" even when two repos share a weighted score.
*
* @param {object} ledger
* @returns {Array<{path:string,name:string,status:string,sessionId:string|null,findingsBySeverity:object,totalFindings:number,weightedScore:number,rank:number}>}
*/
export function buildBacklog(ledger) {
const items = [];
for (const repo of ledger.repos) {
if (repo.status === 'implemented') continue;
const f = repo.findingsBySeverity;
if (!f || typeof f !== 'object') continue;
const findingsBySeverity = Object.fromEntries(
SEVERITIES.map((s) => [s, typeof f[s] === 'number' ? f[s] : 0]),
);
const totalFindings = SEVERITIES.reduce((sum, s) => sum + findingsBySeverity[s], 0);
if (totalFindings === 0) continue;
const weightedScore = SEVERITIES.reduce(
(score, s) => score + findingsBySeverity[s] * SEVERITY_WEIGHTS[s],
0,
);
items.push({
path: repo.path,
name: repo.name,
status: repo.status,
sessionId: repo.sessionId ?? null,
findingsBySeverity,
totalFindings,
weightedScore,
});
}
items.sort(
(x, y) =>
y.weightedScore - x.weightedScore ||
y.findingsBySeverity.critical - x.findingsBySeverity.critical ||
y.findingsBySeverity.high - x.findingsBySeverity.high ||
y.findingsBySeverity.medium - x.findingsBySeverity.medium ||
y.findingsBySeverity.low - x.findingsBySeverity.low ||
x.name.localeCompare(y.name),
);
return items.map((item, i) => ({ ...item, rank: i + 1 }));
}
/**
* 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 };
}