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
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -113,7 +113,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
|
||||||
node --test 'tests/**/*.test.mjs'
|
node --test 'tests/**/*.test.mjs'
|
||||||
```
|
```
|
||||||
|
|
||||||
1138 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
|
1150 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
|
||||||
|
|
||||||
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
|
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
|
||||||
|
|
||||||
|
|
@ -479,6 +479,34 @@ orchestration is judgment-driven and deliberately outside the snapshot suite, ex
|
||||||
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
|
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
|
||||||
command's own context). suite 1127→1138.
|
command's own context). suite 1127→1138.
|
||||||
|
|
||||||
|
### campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)
|
||||||
|
|
||||||
|
The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure
|
||||||
|
lib transform + a read-only CLI-payload field — **no schema change, no new scanner, byte-stable**.
|
||||||
|
|
||||||
|
- **`buildBacklog(ledger)`** (`scanners/lib/campaign-ledger.mjs`, pure, mirrors `rollUp`): the
|
||||||
|
single machine-wide prioritized work list. 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 item is one repo: `{path, name, status, sessionId, findingsBySeverity
|
||||||
|
(normalized), totalFindings, weightedScore, rank}`. **Inclusion:** `status !== 'implemented'`
|
||||||
|
AND `totalFindings > 0` (implemented = done; pending / zero-finding repos have nothing known to
|
||||||
|
fix — they still surface in `rollUp.byStatus`). **Order:** DESC by `weightedScore` (exported
|
||||||
|
`SEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}`), tie-broken lexicographically
|
||||||
|
by critical→high→medium→low count, then ascending `name` — fully deterministic, and the
|
||||||
|
tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high).
|
||||||
|
`rank` is 1-based after the sort.
|
||||||
|
- **`campaign-cli`** now emits `backlog: buildBacklog(ledger)` in both branches (uninitialized →
|
||||||
|
`[]`). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI
|
||||||
|
tests use targeted asserts (not full `deepEqual`), so the new field doesn't break them.
|
||||||
|
- **`commands/campaign.md`** renders the backlog as a "Prioritized backlog" pick-list and points
|
||||||
|
the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).
|
||||||
|
|
||||||
|
**Byte-stability.** `-cli`/lib/command only → scanner count stays **15**, snapshot/backcompat
|
||||||
|
suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). **Deferred to 4c:** per-repo plan
|
||||||
|
export to each repo's `docs/` + reuse of backup/rollback for execution. **Deferred until the first
|
||||||
|
breaking schema change:** `migrateLedger` (4a) — backlog needs no schema bump, so building
|
||||||
|
migration now would be speculative (`schemaVersion` is already stamped for when it's needed).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 15 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, and output-style detection. Zero external dependencies.
|
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 15 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, and output-style detection. Zero external dependencies.
|
||||||
|
|
@ -293,7 +293,7 @@ Your team configuration changes over time. Track it:
|
||||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
||||||
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll |
|
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll |
|
||||||
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity, resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) |
|
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a single **cross-repo prioritized backlog** to pick from (severity-weighted), resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) |
|
||||||
| `/config-audit discover` | Run discovery phase only |
|
| `/config-audit discover` | Run discovery phase only |
|
||||||
| `/config-audit analyze` | Run analysis phase only |
|
| `/config-audit analyze` | Run analysis phase only |
|
||||||
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ Every mutation — creating the ledger, adding a repo, changing a status — is
|
||||||
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
|
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
|
||||||
The command never hand-edits the ledger JSON.
|
The command never hand-edits the ledger JSON.
|
||||||
|
|
||||||
This is the **THIN** campaign surface (ledger + roll-up + status). Cross-repo backlog
|
This is the **THIN** campaign surface (ledger + roll-up + status + a cross-repo prioritized
|
||||||
prioritization and execution are a later block — this command does not run audits or apply
|
backlog to pick from). Execution is a later block — this command does not run audits or apply
|
||||||
fixes itself; it tracks where each repo stands.
|
fixes itself; it tracks where each repo stands and shows what to tackle next.
|
||||||
|
|
||||||
## Two CLIs back this command
|
## Two CLIs back this command
|
||||||
|
|
||||||
|
|
@ -65,8 +65,10 @@ attempt a write over a corrupt ledger.)
|
||||||
|
|
||||||
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
|
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
|
||||||
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
|
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
|
||||||
sessionId, findingsBySeverity, updatedDate`), and `rollUp {totalRepos, byStatus, bySeverity,
|
sessionId, findingsBySeverity, updatedDate`), `rollUp {totalRepos, byStatus, bySeverity,
|
||||||
reposWithFindings}`.
|
reposWithFindings}`, and `backlog[]` — the single cross-repo prioritized work list (each:
|
||||||
|
`path, name, status, findingsBySeverity, totalFindings, weightedScore, rank`), already sorted
|
||||||
|
DESC by severity (most critical work first).
|
||||||
|
|
||||||
Present it as two short tables:
|
Present it as two short tables:
|
||||||
|
|
||||||
|
|
@ -84,6 +86,19 @@ Present it as two short tables:
|
||||||
| Repo | Status | Findings (C/H/M/L) | Last updated |
|
| Repo | Status | Findings (C/H/M/L) | Last updated |
|
||||||
|------|--------|--------------------|--------------|
|
|------|--------|--------------------|--------------|
|
||||||
|
|
||||||
|
**Prioritized backlog** — the one cross-repo list to pick from, highest-severity work first.
|
||||||
|
Render `backlog[]` in `rank` order (it is already sorted); omit this table entirely when the
|
||||||
|
backlog is empty (nothing outstanding — say "Backlog clear — no outstanding findings across
|
||||||
|
tracked repos."). Implemented repos and repos with no known findings are deliberately absent.
|
||||||
|
|
||||||
|
| # | Repo | Status | Findings (C/H/M/L) | Total |
|
||||||
|
|---|------|--------|--------------------|-------|
|
||||||
|
|
||||||
|
After it, point the user at the top item: "Highest priority: **`<name>`** (`<status>`) — pick it
|
||||||
|
with `/config-audit` (audit), `/config-audit plan`, or `/config-audit implement` in that repo,
|
||||||
|
then record progress here with `set-status`." The backlog is a **pick-list**, not an executor —
|
||||||
|
this command does not run audits or fixes (that is the later execution block).
|
||||||
|
|
||||||
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
|
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
|
||||||
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
|
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
|
||||||
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
|
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
|
||||||
|
|
@ -173,5 +188,6 @@ Tailor to where the campaign stands:
|
||||||
and `knowledge-refresh`).
|
and `knowledge-refresh`).
|
||||||
- The `-cli` suffix keeps both CLIs out of the scan-orchestrator, so the scanner count and the
|
- The `-cli` suffix keeps both CLIs out of the scan-orchestrator, so the scanner count and the
|
||||||
byte-stable snapshot suite are unaffected.
|
byte-stable snapshot suite are unaffected.
|
||||||
- **THIN scope:** ledger + roll-up + status only. Cross-repo backlog prioritization and execution
|
- **THIN scope:** ledger + roll-up + status + a read-only cross-repo prioritized backlog. The
|
||||||
are a later block — this command tracks state, it does not run audits or apply fixes.
|
backlog is a derived pick-list (`buildBacklog`, severity-weighted); execution is a later block —
|
||||||
|
this command tracks state and shows what to tackle next, it does not run audits or apply fixes.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
loadLedger,
|
loadLedger,
|
||||||
validateLedger,
|
validateLedger,
|
||||||
rollUp,
|
rollUp,
|
||||||
|
buildBacklog,
|
||||||
defaultLedgerPath,
|
defaultLedgerPath,
|
||||||
} from './lib/campaign-ledger.mjs';
|
} from './lib/campaign-ledger.mjs';
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ async function main() {
|
||||||
updatedDate: null,
|
updatedDate: null,
|
||||||
repos: [],
|
repos: [],
|
||||||
rollUp: rollUp({ repos: [] }),
|
rollUp: rollUp({ repos: [] }),
|
||||||
|
backlog: buildBacklog({ repos: [] }),
|
||||||
};
|
};
|
||||||
exitCode = 1; // advisory: there is no campaign to report yet
|
exitCode = 1; // advisory: there is no campaign to report yet
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -88,6 +90,7 @@ async function main() {
|
||||||
updatedDate: ledger.updatedDate,
|
updatedDate: ledger.updatedDate,
|
||||||
repos: ledger.repos,
|
repos: ledger.repos,
|
||||||
rollUp: rollUp(ledger),
|
rollUp: rollUp(ledger),
|
||||||
|
backlog: buildBacklog(ledger),
|
||||||
};
|
};
|
||||||
exitCode = 0;
|
exitCode = 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'impleme
|
||||||
/** Severity buckets aggregated by the machine-wide roll-up. */
|
/** Severity buckets aggregated by the machine-wide roll-up. */
|
||||||
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
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}$/;
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
/** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */
|
/** 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 };
|
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
|
* 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).
|
* so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt).
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import { tmpdir, homedir } from 'node:os';
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_SCHEMA_VERSION,
|
CAMPAIGN_SCHEMA_VERSION,
|
||||||
STATUSES,
|
STATUSES,
|
||||||
|
SEVERITY_WEIGHTS,
|
||||||
createLedger,
|
createLedger,
|
||||||
addRepo,
|
addRepo,
|
||||||
setRepoStatus,
|
setRepoStatus,
|
||||||
rollUp,
|
rollUp,
|
||||||
|
buildBacklog,
|
||||||
validateLedger,
|
validateLedger,
|
||||||
defaultLedgerPath,
|
defaultLedgerPath,
|
||||||
loadLedger,
|
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', () => {
|
describe('validateLedger', () => {
|
||||||
const good = () => setRepoStatus(
|
const good = () => setRepoStatus(
|
||||||
addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW }),
|
addRepo(createLedger({ now: NOW }), { path: '/r/foo', name: 'foo' }, { now: NOW }),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
addRepo,
|
addRepo,
|
||||||
setRepoStatus,
|
setRepoStatus,
|
||||||
rollUp,
|
rollUp,
|
||||||
|
buildBacklog,
|
||||||
saveLedger,
|
saveLedger,
|
||||||
} from '../../scanners/lib/campaign-ledger.mjs';
|
} 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', () => {
|
describe('campaign-cli — --output-file', () => {
|
||||||
it('writes JSON to the file and stays silent on stdout', async () => {
|
it('writes JSON to the file and stays silent on stdout', async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
const dir = mkdtempSync(join(tmpdir(), 'camp-cli-'));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue