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:
parent
ee0c762151
commit
49833aded8
7 changed files with 263 additions and 10 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue