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
|
|
@ -6,10 +6,12 @@ import { tmpdir, homedir } from 'node:os';
|
|||
import {
|
||||
CAMPAIGN_SCHEMA_VERSION,
|
||||
STATUSES,
|
||||
SEVERITY_WEIGHTS,
|
||||
createLedger,
|
||||
addRepo,
|
||||
setRepoStatus,
|
||||
rollUp,
|
||||
buildBacklog,
|
||||
validateLedger,
|
||||
defaultLedgerPath,
|
||||
loadLedger,
|
||||
|
|
@ -153,6 +155,109 @@ describe('rollUp', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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 }),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
addRepo,
|
||||
setRepoStatus,
|
||||
rollUp,
|
||||
buildBacklog,
|
||||
saveLedger,
|
||||
} from '../../scanners/lib/campaign-ledger.mjs';
|
||||
|
||||
|
|
@ -126,6 +127,39 @@ describe('campaign-cli — payload shape', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('campaign-cli — backlog', () => {
|
||||
it('emits a backlog matching the lib, excluding implemented/pending/zero-finding repos', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
||||
const { file, ledger } = await seedLedger(dir);
|
||||
const out = JSON.parse(runCli(['--ledger-file', file]).stdout);
|
||||
// seed: a=audited(with findings), b=implemented(excluded), c=pending(excluded)
|
||||
assert.deepEqual(out.backlog, buildBacklog(ledger));
|
||||
assert.equal(out.backlog.length, 1);
|
||||
assert.equal(out.backlog[0].name, 'a');
|
||||
assert.equal(out.backlog[0].rank, 1);
|
||||
});
|
||||
|
||||
it('ranks multiple actionable repos by severity', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
||||
let l = createLedger({ now: NOW });
|
||||
l = addRepo(l, { path: '/r/low', name: 'low' }, { now: NOW });
|
||||
l = addRepo(l, { path: '/r/crit', name: 'crit' }, { now: NOW });
|
||||
l = setRepoStatus(l, '/r/low', 'audited', { now: NOW, findingsBySeverity: { critical: 0, high: 0, medium: 0, low: 9 } });
|
||||
l = setRepoStatus(l, '/r/crit', 'planned', { now: NOW, findingsBySeverity: { critical: 1, high: 0, medium: 0, low: 0 } });
|
||||
const file = join(dir, 'campaign-ledger.json');
|
||||
await saveLedger(file, l);
|
||||
|
||||
const out = JSON.parse(runCli(['--ledger-file', file]).stdout);
|
||||
assert.deepEqual(out.backlog.map((i) => i.name), ['crit', 'low']);
|
||||
});
|
||||
|
||||
it('uninitialized payload has an empty backlog', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
||||
const out = JSON.parse(runCli(['--ledger-file', join(dir, 'nope.json')]).stdout);
|
||||
assert.deepEqual(out.backlog, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaign-cli — --output-file', () => {
|
||||
it('writes JSON to the file and stays silent on stdout', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue