feat(config-audit): cache-prefix stability scanner CPS (v5 N3) [skip-docs]

New CPS scanner walks CLAUDE.md cascade and flags volatile content
between lines 31 and 150 — the cache-prefix window beyond TOK Pattern
A's top-30 territory. Volatile content anywhere in the cached prefix
forces a fresh cache write from that line down on every turn.

Volatile-pattern set extends TOK Pattern A with:
- shell-exec lines (! prefix) — common in CLAUDE.md to inject git/date
- ${VAR} substitutions — vary per-shell, defeat cache reuse

Severity: medium per finding. Skips lines 1-30 to avoid duplicating
Pattern A's range; CPS' value is in the 31-150 zone.

Wired into scan-orchestrator + scoring SCANNER_AREA_MAP. CPS shares
the "Token Efficiency" area with TOK; scoreByArea now deduplicates by
area name and combines counts across scanners contributing to the
same area, so the 9-area scorecard contract holds.

Fixtures volatile-mid-section/{volatile-line-60, volatile-line-200}
verify both positive (line 60) and out-of-window (line 200) cases.

[skip-docs] reason: v5 plan fences off README/CLAUDE.md badge updates
to Session 5; Forgejo pre-commit-docs-gate hook requires this tag.

Tests: 604 → 611 (+7).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-01 07:37:54 +02:00
commit 65087e624f
6 changed files with 517 additions and 9 deletions

View file

@ -151,6 +151,7 @@ const SCANNER_AREA_MAP = {
CNF: 'Conflicts',
GAP: 'Feature Coverage',
TOK: 'Token Efficiency',
CPS: 'Token Efficiency',
};
/**
@ -181,21 +182,35 @@ function severityPenalty(counts) {
* @returns {{ areas: Array<{ id: string, name: string, grade: string, score: number, findingCount: number }>, overallGrade: string, scoringVersion: string }}
*/
export function scoreByArea(scannerResults) {
const areas = [];
// Group scanner results by area name so multiple scanners that share an area
// (e.g. TOK + CPS both → "Token Efficiency") produce one combined row.
const grouped = new Map();
for (const result of scannerResults) {
const name = SCANNER_AREA_MAP[result.scanner] || result.scanner;
const findingCount = result.findings.length;
if (!grouped.has(name)) grouped.set(name, []);
grouped.get(name).push(result);
}
const areas = [];
for (const [name, results] of grouped) {
const findings = results.flatMap(r => r.findings || []);
const findingCount = findings.length;
let score;
if (result.scanner === 'GAP') {
const util = calculateUtilization(result.findings);
if (results.some(r => r.scanner === 'GAP')) {
// GAP scoring uses utilization, not severity penalty
const util = calculateUtilization(findings);
score = util.score;
} else {
// v5 severity-weighted: penalty proportional to a per-scanner budget.
// maxBudget = max(10, findingCount * 4) — adding more lows doesn't crater the
// grade, but a single high-severity finding consumes a large fraction of budget.
const counts = result.counts || {};
// v5 severity-weighted: penalty proportional to a per-area budget.
// Combine counts across all scanners contributing to this area.
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const r of results) {
for (const k of Object.keys(counts)) {
counts[k] += (r.counts && r.counts[k]) || 0;
}
}
const penalty = severityPenalty(counts);
const maxBudget = Math.max(10, findingCount * 4);
const passRate = Math.max(0, 100 - (penalty / maxBudget) * 100);