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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 02:44:21 +02:00
commit 49833aded8
7 changed files with 263 additions and 10 deletions

View file

@ -28,6 +28,7 @@ import {
loadLedger,
validateLedger,
rollUp,
buildBacklog,
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
@ -72,6 +73,7 @@ async function main() {
updatedDate: null,
repos: [],
rollUp: rollUp({ repos: [] }),
backlog: buildBacklog({ repos: [] }),
};
exitCode = 1; // advisory: there is no campaign to report yet
} else {
@ -88,6 +90,7 @@ async function main() {
updatedDate: ledger.updatedDate,
repos: ledger.repos,
rollUp: rollUp(ledger),
backlog: buildBacklog(ledger),
};
exitCode = 0;
}

View file

@ -33,6 +33,14 @@ export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'impleme
/** 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. */
@ -135,6 +143,65 @@ export function rollUp(ledger) {
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
* criticalhighmediumlow 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).