config-audit/tests/scanners/agent-listing-scanner.test.mjs
Kjell Tore Guttormsen 7a794b47eb fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.

Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.

scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.

IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.

unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.

Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.

Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.

Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).

Suite 1535 -> 1573, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:26:36 +02:00

281 lines
13 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { scan } from '../../scanners/agent-listing-scanner.mjs';
import { PER_AGENT_DESC_SOFT_CAP } from '../../scanners/lib/agent-listing-budget.mjs';
/**
* AGT scanner — agent-listing always-loaded token budget.
*
* Claude Code injects a listing of every active agent's name+description into the
* system prompt so the model knows which subagents it can delegate to. With ~100
* agents that listing is a large always-loaded cost. UNLIKE the skill listing,
* the agent-listing mechanism is NOT documented (agents are absent from CC's
* deferred / always-loaded context breakdown), so AGT findings are an INFERRED
* upper-bound estimate, not a verified truncation like SKL-001. The aggregate
* budget is a config-audit heuristic anchored on a conservative 200k window.
*
* Token heuristic mirrors estimateTokens('markdown'): ceil(chars/4).
* 16 agents * 1000 desc-chars = 16000 chars -> 4000 tok == budget (NOT over).
* 17 agents * 1000 desc-chars = 17000 chars -> 4250 tok > budget (over).
*/
const AGGREGATE_BUDGET_TOKENS = 4000; // heuristic: 0.02 * 200_000 (anchored, disclosed)
function uniqueDir(suffix) {
return join(tmpdir(), `config-audit-agt-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
}
/**
* The AGT scanner enumerates active agents via process.env.HOME. Tests must
* override HOME, run, restore — never rely on the developer's real ~/.claude.
*/
async function runScannerWithHome(home) {
const original = process.env.HOME;
process.env.HOME = home;
try {
return await scan('/unused', { files: [] });
} finally {
process.env.HOME = original;
}
}
/** Write one user agent (name + description of `descLen` chars) into a HOME. */
async function addUserAgent(home, name, descLen) {
const dir = join(home, '.claude', 'agents');
await mkdir(dir, { recursive: true });
const desc = 'a'.repeat(descLen);
await writeFile(
join(dir, `${name}.md`),
`---\nname: ${name}\ndescription: ${desc}\n---\nYou are the ${name} agent. Body of the agent prompt.\n`,
);
}
/** Build a fake HOME with `count` user agents, each description `descLen` chars. */
async function homeWithNUserAgents(count, descLen, prefix = 'agg') {
const home = uniqueDir(`${prefix}-${count}x${descLen}`);
for (let i = 0; i < count; i++) {
await addUserAgent(home, `${prefix}${i}`, descLen);
}
return home;
}
/** Build a fake HOME with a single user agent whose description is `descLen` chars. */
async function homeWithOneAgent(descLen, name = 'solo') {
const home = uniqueDir(`one-${name}-${descLen}`);
await addUserAgent(home, name, descLen);
return home;
}
const findAggregate = (findings) => findings.find(f => /agent listing/i.test(f.title));
// Per-agent advisories are every finding that is NOT the aggregate roll-up.
const findPerAgent = (findings) => findings.filter(f => !/agent listing/i.test(f.title));
describe('AGT scanner — basic structure', () => {
it('reports scanner prefix AGT', async () => {
const home = uniqueDir('empty');
try {
await mkdir(join(home, '.claude'), { recursive: true });
const result = await runScannerWithHome(home);
assert.equal(result.scanner, 'AGT');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('finding IDs match CA-AGT-NNN pattern', async () => {
const home = await homeWithNUserAgents(17, 1000);
try {
const result = await runScannerWithHome(home);
for (const f of result.findings) assert.match(f.id, /^CA-AGT-\d{3}$/);
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('AGT scanner — aggregate always-loaded budget', () => {
it('fires a low-severity finding when the agent listing exceeds the 200k-anchored budget', async () => {
const home = await homeWithNUserAgents(17, 1000); // 17000 chars -> ~4250 tok > 4000
try {
const result = await runScannerWithHome(home);
const agg = findAggregate(result.findings);
assert.ok(agg, `expected an aggregate finding; got: ${result.findings.map(f => f.title).join(' | ')}`);
assert.equal(agg.severity, 'low', `aggregate should be low severity, got ${agg.severity}`);
assert.match(agg.id, /^CA-AGT-\d{3}$/);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does NOT fire when only a few small agents are active', async () => {
const home = await homeWithNUserAgents(3, 1000); // 3000 chars -> 750 tok < 4000
try {
const result = await runScannerWithHome(home);
assert.equal(findAggregate(result.findings), undefined,
`expected no aggregate finding; got: ${result.findings.map(f => f.title).join(' | ')}`);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('treats the budget as a strict boundary: 16x1000 (at budget) no fire, 17x1000 (over) fires', async () => {
const atBudget = await homeWithNUserAgents(16, 1000, 'at'); // 16000 chars -> 4000 tok == budget
try {
const result = await runScannerWithHome(atBudget);
assert.equal(findAggregate(result.findings), undefined,
'aggregate exactly at the budget must not fire (strictly-greater rule)');
} finally {
await rm(atBudget, { recursive: true, force: true });
}
const overBudget = await homeWithNUserAgents(17, 1000, 'over'); // 17000 chars -> 4250 tok
try {
const result = await runScannerWithHome(overBudget);
assert.ok(findAggregate(result.findings), 'aggregate one step over the budget must fire');
} finally {
await rm(overBudget, { recursive: true, force: true });
}
});
it('DISCLOSES the inferred mechanism + upper-bound estimate + anchor (intellectual honesty)', async () => {
const home = await homeWithNUserAgents(17, 1000);
try {
const result = await runScannerWithHome(home);
const agg = findAggregate(result.findings);
assert.ok(agg, 'expected an aggregate finding');
const ev = String(agg.evidence);
assert.match(ev, /infer/i, 'evidence must disclose the always-loaded mechanism is INFERRED, not documented');
assert.match(ev, /estimate|upper.?bound/i, 'evidence must flag the figure as an estimate / upper bound');
assert.match(ev, new RegExp(String(AGGREGATE_BUDGET_TOKENS)), 'evidence must state the budget');
assert.match(ev, /200k/, 'evidence must anchor the budget on a 200k window');
assert.match(ev, /1,000,000|1M|20,000/, 'evidence must note the 1M-context scaling');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('recommends the reduction levers (disable unused plugins / trim descriptions)', async () => {
const home = await homeWithNUserAgents(17, 1000);
try {
const result = await runScannerWithHome(home);
const agg = findAggregate(result.findings);
assert.ok(agg, 'expected an aggregate finding to carry remediation');
const rec = String(agg.recommendation);
assert.match(rec, /disable|plugin/i, 'recommendation should mention disabling unused plugins');
assert.match(rec, /trim|description/i, 'recommendation should mention trimming descriptions');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('an empty HOME (no agents) yields zero findings', async () => {
const home = uniqueDir('noagents');
try {
await mkdir(join(home, '.claude'), { recursive: true });
const result = await runScannerWithHome(home);
assert.equal(result.findings.length, 0);
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
/**
* Per-agent description advisory (the B1-rest companion to the aggregate).
*
* A single agent with a long description is flagged on its own: every char of
* that description re-enters context in the always-loaded agent listing on every
* turn. UNLIKE CA-SKL-001, this is NOT a truncation finding — agents have no
* verified per-description cap — so the advisory is a soft heuristic (low, the
* same 500-char bloat threshold TOK pattern F uses for SKILL.md descriptions),
* not a hard "Claude Code drops the tail" claim.
*/
describe('AGT scanner — per-agent description advisory', () => {
it('fires a low-severity advisory when one agent description exceeds the soft cap', async () => {
// One agent, description over the cap; aggregate stays well under budget so
// the per-agent advisory is the only finding (clean isolation).
const home = await homeWithOneAgent(PER_AGENT_DESC_SOFT_CAP + 100);
try {
const result = await runScannerWithHome(home);
assert.equal(findAggregate(result.findings), undefined,
'a single small-aggregate agent must not trip the aggregate budget');
const perAgent = findPerAgent(result.findings);
assert.equal(perAgent.length, 1,
`expected exactly one per-agent advisory; got: ${result.findings.map(f => f.title).join(' | ')}`);
assert.equal(perAgent[0].severity, 'low', `per-agent advisory should be low, got ${perAgent[0].severity}`);
assert.match(perAgent[0].id, /^CA-AGT-\d{3}$/);
assert.match(`${perAgent[0].description} ${perAgent[0].evidence}`, /solo/,
'the advisory should name the offending agent');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('treats the soft cap as a strict boundary: at-cap no fire, one-over fires', async () => {
const atCap = await homeWithOneAgent(PER_AGENT_DESC_SOFT_CAP, 'atcap');
try {
const result = await runScannerWithHome(atCap);
assert.equal(findPerAgent(result.findings).length, 0,
'a description exactly at the soft cap must not fire (strictly-greater rule)');
} finally {
await rm(atCap, { recursive: true, force: true });
}
const overCap = await homeWithOneAgent(PER_AGENT_DESC_SOFT_CAP + 1, 'overcap');
try {
const result = await runScannerWithHome(overCap);
assert.equal(findPerAgent(result.findings).length, 1,
'a description one char over the soft cap must fire');
} finally {
await rm(overCap, { recursive: true, force: true });
}
});
it('does NOT claim truncation and DISCLOSES the advisory/heuristic framing (intellectual honesty)', async () => {
const home = await homeWithOneAgent(PER_AGENT_DESC_SOFT_CAP + 100);
try {
const result = await runScannerWithHome(home);
const advisory = findPerAgent(result.findings)[0];
assert.ok(advisory, 'expected a per-agent advisory');
const text = `${advisory.description} ${advisory.evidence}`;
assert.doesNotMatch(text, /truncat/i,
'agents have no verified cap — the advisory must NOT claim Claude Code truncates the description');
assert.match(text, /every turn|always.?loaded/i,
'the advisory must explain the description re-enters context every turn');
assert.match(String(advisory.evidence), new RegExp(String(PER_AGENT_DESC_SOFT_CAP)),
'evidence must state the soft-cap threshold so it reads as a heuristic');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('recommends trimming the description toward its trigger phrases', async () => {
const home = await homeWithOneAgent(PER_AGENT_DESC_SOFT_CAP + 100);
try {
const result = await runScannerWithHome(home);
const advisory = findPerAgent(result.findings)[0];
assert.ok(advisory, 'expected a per-agent advisory to carry remediation');
assert.match(String(advisory.recommendation), /trim|trigger|shorten/i,
'recommendation should point at trimming the description');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('emits per-agent advisories BEFORE the aggregate roll-up', async () => {
// 17 large agents → both per-agent advisories AND the aggregate fire.
const home = await homeWithNUserAgents(17, 1000);
try {
const result = await runScannerWithHome(home);
const agg = findAggregate(result.findings);
assert.ok(agg, 'expected the aggregate to fire alongside per-agent advisories');
const aggIndex = result.findings.indexOf(agg);
assert.equal(aggIndex, result.findings.length - 1,
'the aggregate roll-up must be emitted last (per-agent advisories precede it, mirroring SKL 001→002)');
assert.equal(findPerAgent(result.findings).length, 17,
'each of the 17 over-cap agents should carry its own advisory');
} finally {
await rm(home, { recursive: true, force: true });
}
});
});