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>
289 lines
11 KiB
JavaScript
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 };
|
|
}
|