CC 2.1.226's /doctor Check 8 covers auto mode with usage-weighted judgement. The binding positioning forbids carrying a feature whose whole value is duplicating a /doctor check, so the "adopt this feature" nudge goes. The deterministic side stays: SET still validates autoMode structure and still flags it as dead config in shared project settings. GAP dimensions 25 -> 24. The title lived in FOUR tables, not the two the removal was scoped against: the dimension list, scoring TITLE_TO_ID, the humanizer's static translations, and the scoring denominators (TIER_COUNTS t3 8->7, TOTAL_DIMENSIONS 25->24, MAX_WEIGHTED 42->41) -- the one that moves a user-visible number. findGapId falls back to 'unknown' silently, so a partial removal would have degraded without failing. A blanket sync invariant now asserts all four against GAP_CHECKS instead of comparing occurrences pairwise; each arm was verified red against its own defect (denominator drift, orphaned humanizer entry, resurrected dimension). Frozen tests/snapshots/v5.0.0/ stays untouched. strip-retired-gap.mjs is the removal twin of strip-added-scanner.mjs: it strips the retired dimension from whichever side still carries it and re-derives GAP IDs, since retiring a dimension from mid-list shifts every later ID by one. Derived utilization figures are dropped from comparison rather than recomputed -- recomputing them in a test helper would assert the new arithmetic against itself, and scoring.test.mjs already pins them exactly. Re-seeding was rejected: it would silently bake in any other drift across every scanner those four files cover. risk_score, risk_band, verdict, overallGrade, maturity and segment are byte-identical across the change (severity info carries zero risk weight; GAP is excluded from the overall grade). Utilization shifts 43 -> 44 on the fixture. D2 (CA-SKL-002) is NOT removed. Verified against the primary source first: the CC changelog carries exactly one budget-fraction statement (L3786, 2.1.32) and nothing supersedes it, so our 2% is current and 002 is not a duplicate with a stale figure. /doctor's ~1% could not be reconciled from the changelog and it discloses its own numbers as disk estimates, so it is recorded, not adopted. Left explicitly unverified in a code note: L3786 says "character budget" while we express tokens -- a 4x difference nobody can settle from the wording. Suite 1531 -> 1535, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RsfPGxgwbR3MY54wDC6hat
203 lines
7.7 KiB
JavaScript
203 lines
7.7 KiB
JavaScript
/**
|
|
* The REMOVAL twin of strip-added-scanner.mjs.
|
|
*
|
|
* The frozen v5.0.0 baselines predate v5.14's retirement of the GAP dimension
|
|
* `No autoMode classifier` (a pure /doctor Check 8 duplicate under the binding
|
|
* positioning). Where strip-added-scanner.mjs removes post-v5.0.0 ADDITIONS
|
|
* from live output so it matches the frozen expectation, this removes a
|
|
* RETIRED dimension from whichever side still carries it — the frozen
|
|
* expectation. Both sides run through the same normalizer, so every operation
|
|
* here must be a no-op on live output; that constraint is what dictates the
|
|
* three shapes below.
|
|
*
|
|
* Why strip rather than re-seed: re-seeding these four baselines would bake in
|
|
* whatever OTHER drift has accumulated since v5.0.0 across every scanner they
|
|
* cover, silently. Stripping keeps the strongest invariant — every byte the
|
|
* original scanners emit is still compared — and confines the exception to the
|
|
* one dimension we deliberately deleted.
|
|
*
|
|
* Three payload shapes carry GAP findings, all derived from one scan and all
|
|
* therefore in emission order:
|
|
* - envelopes (scan-orchestrator, posture) — `scanners[GAP].findings`
|
|
* - drift — flat `newFindings`/`resolvedFindings`/`unchangedFindings`/`movedFindings`
|
|
* - fix-cli — `manual[]` entries keyed `findingId`
|
|
* A single traversal handles all three rather than three special cases.
|
|
*
|
|
* What is adjusted, and what is deliberately NOT:
|
|
* - the retired finding is dropped, and GAP IDs are re-derived densely in
|
|
* traversal order. GAP IDs are already position-derived (`output.mjs`
|
|
* assigns them from an emission counter), so this re-derivation applies the
|
|
* scanner's own rule, not a new one. Retiring a dimension from the middle
|
|
* of the list shifts every later ID by one.
|
|
* - counter bookkeeping is decremented mechanically, exactly as
|
|
* strip-added-scanner.mjs decrements `scanners_ok`.
|
|
* - the DERIVED utilization figures (`utilization.score`/`overhang` and the
|
|
* feature_coverage area `score`) are dropped from comparison instead of
|
|
* recomputed. Recomputing them inside a test helper would assert the new
|
|
* arithmetic against itself. They are covered exhaustively by dedicated
|
|
* unit tests in tests/lib/scoring.test.mjs (exact expected values per
|
|
* tier), which is the right place for arithmetic.
|
|
* - risk_score, risk_band, verdict, overallGrade, maturity, segment and
|
|
* drift's scoreChange are left alone: the retired dimension was
|
|
* severity `info`, which carries zero risk weight, and GAP is excluded from
|
|
* the overall grade (scoring.mjs). Measured 2026-08-09: all byte-identical
|
|
* across the change.
|
|
*
|
|
* Mutates in place and returns the payload for chaining inside a normalizer.
|
|
*/
|
|
|
|
const RETIRED_GAP_TITLES = new Set(['No autoMode classifier']);
|
|
|
|
const GAP_ID_RE = /^CA-GAP-\d+$/;
|
|
|
|
/** Decrement a numeric field by n when it exists. */
|
|
function decrement(obj, key, n) {
|
|
if (obj && typeof obj[key] === 'number') obj[key] -= n;
|
|
}
|
|
|
|
/**
|
|
* True for a FINDING-shaped object belonging to the GAP scanner.
|
|
*
|
|
* The `title` requirement is load-bearing: the scanner envelope entry also
|
|
* carries `scanner: 'GAP'`, and counting it as a finding shifts every
|
|
* re-derived ID by one.
|
|
*/
|
|
function isGapEntry(o) {
|
|
if (!o || typeof o !== 'object' || typeof o.title !== 'string') return false;
|
|
if (o.scanner === 'GAP') return true;
|
|
return GAP_ID_RE.test(o.id || '') || GAP_ID_RE.test(o.findingId || '');
|
|
}
|
|
|
|
/**
|
|
* Walk the payload, removing retired GAP entries from every array and
|
|
* collecting the survivors in traversal (= emission) order.
|
|
*/
|
|
function walk(node, state) {
|
|
if (Array.isArray(node)) {
|
|
for (let i = node.length - 1; i >= 0; i--) {
|
|
const item = node[i];
|
|
if (isGapEntry(item) && RETIRED_GAP_TITLES.has(item.title)) {
|
|
state.removed++;
|
|
if (item.severity) {
|
|
state.bySeverity[item.severity] = (state.bySeverity[item.severity] || 0) + 1;
|
|
}
|
|
node.splice(i, 1);
|
|
}
|
|
}
|
|
for (const item of node) {
|
|
if (isGapEntry(item)) state.survivors.push(item);
|
|
walk(item, state);
|
|
}
|
|
return;
|
|
}
|
|
if (node && typeof node === 'object') {
|
|
for (const v of Object.values(node)) walk(v, state);
|
|
}
|
|
}
|
|
|
|
/** Decrement every counter location a retired GAP finding contributes to. */
|
|
function adjustCounters(payload, state) {
|
|
const { removed, bySeverity } = state;
|
|
|
|
const envelopes = [payload, payload.scannerEnvelope].filter(
|
|
e => e && typeof e === 'object',
|
|
);
|
|
for (const env of envelopes) {
|
|
if (Array.isArray(env.scanners)) {
|
|
for (const s of env.scanners) {
|
|
if (s && s.scanner === 'GAP') {
|
|
for (const [sev, n] of Object.entries(bySeverity)) decrement(s.counts, sev, n);
|
|
}
|
|
}
|
|
}
|
|
if (env.aggregate) {
|
|
decrement(env.aggregate, 'total_findings', removed);
|
|
for (const [sev, n] of Object.entries(bySeverity)) decrement(env.aggregate.counts, sev, n);
|
|
}
|
|
}
|
|
|
|
// Posture-shaped roll-ups above the envelope.
|
|
decrement(payload, 'opportunityCount', removed);
|
|
if (Array.isArray(payload.areas)) {
|
|
for (const a of payload.areas) {
|
|
if (a && a.id === 'feature_coverage') decrement(a, 'findingCount', removed);
|
|
}
|
|
}
|
|
|
|
// Drift totals.
|
|
if (payload.summary) {
|
|
decrement(payload.summary, 'totalBefore', removed);
|
|
decrement(payload.summary, 'totalAfter', removed);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip retired GAP dimensions from any CLI payload and drop the derived
|
|
* utilization figures from comparison.
|
|
* @template T
|
|
* @param {T} payload
|
|
* @returns {T}
|
|
*/
|
|
export function stripRetiredGap(payload) {
|
|
if (!payload || typeof payload !== 'object') return payload;
|
|
|
|
const state = { removed: 0, bySeverity: {}, survivors: [] };
|
|
walk(payload, state);
|
|
|
|
if (state.removed > 0) {
|
|
// Re-derive GAP IDs densely — the same position rule output.mjs applies.
|
|
state.survivors.forEach((f, i) => {
|
|
const id = `CA-GAP-${String(i + 1).padStart(3, '0')}`;
|
|
if ('id' in f) f.id = id;
|
|
if ('findingId' in f) f.findingId = id;
|
|
});
|
|
adjustCounters(payload, state);
|
|
}
|
|
|
|
// Derived scores: dropped on BOTH sides unconditionally, so live and frozen
|
|
// stay symmetric. See the header note.
|
|
if (payload.utilization && typeof payload.utilization === 'object') {
|
|
delete payload.utilization.score;
|
|
delete payload.utilization.overhang;
|
|
}
|
|
if (Array.isArray(payload.areas)) {
|
|
for (const a of payload.areas) {
|
|
if (a && a.id === 'feature_coverage') delete a.score;
|
|
}
|
|
}
|
|
// Drift reports the same derived score under a different key shape.
|
|
if (Array.isArray(payload.areaChanges)) {
|
|
for (const a of payload.areaChanges) {
|
|
if (a && a.name === 'Feature Coverage') {
|
|
delete a.before?.score;
|
|
delete a.after?.score;
|
|
}
|
|
}
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Mask the GAP finding tally in a captured stderr scorecard.
|
|
*
|
|
* The scorecard prints only a count, with nothing in the text identifying WHICH
|
|
* dimensions it covers — so unlike the line-removal in stripAddedScannerStderr,
|
|
* a decrement here could not be a no-op on the live side. Both sides go through
|
|
* the same normalizer, so the tally is masked rather than adjusted; the exact
|
|
* GAP count is asserted on the structured payload, where the findings
|
|
* themselves are compared.
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function maskGapTallyStderr(text) {
|
|
if (typeof text !== 'string') return text;
|
|
return text
|
|
.replace(
|
|
/^(\s*`?\[GAP\][^\n]*?)\d+ finding\(s\)/gm,
|
|
(_m, head) => `${head}<GAPCOUNT> finding(s)`,
|
|
)
|
|
// The scorecard footer repeats the tally, in a raw and a humanized wording.
|
|
.replace(/^(\s*)\d+( opportunities available)/gm, '$1<GAPCOUNT>$2')
|
|
.replace(/^(\s*)\d+( ways you could get more out of)/gm, '$1<GAPCOUNT>$2');
|
|
}
|