config-audit/tests/scanners/token-hotspots.test.mjs
Kjell Tore Guttormsen ba9f82f952 feat(tokens): stale plugin-cache disk-cleanup finding, honestly labelled Dead config (v5.9 B3b)
The B3a cache filter already exposes discovery.staleCacheVersions; surface them
as a finding so the user knows the superseded plugin versions are safe to delete.

Honesty (Verifiseringsplikt): the finding loads on ZERO turns, so it must NOT
read as a per-turn token cost. TOK normally humanizes to "Wasted tokens"; a new
per-finding category override ('plugin-cache-hygiene' -> "Dead config") plus a
dedicated humanizer translation ("Old plugin versions are sitting on disk (safe
to delete) ... cost zero tokens per turn ... housekeeping, not a performance
problem") keep the prose accurate instead of the generic "using more space"
default. evidence carries the explicit "zero live-context impact" note.

- token-hotspots: Pattern H emits CA-TOK (low) from discovery.staleCacheVersions
  when stale versions exist (`--global`); silent otherwise.
- humanizer: CATEGORY_TO_IMPACT lets a finding's category override the
  scanner-default impact label (raw `category` field unchanged -> --json/--raw
  byte-stable). humanizer-data: honest static translation for the finding.
- Tests: finding fires/severity/category, lists stale keys + zero-impact note,
  silent when none; humanizer override -> Dead config; honest translation locked.
- Docs: tokens command render note (disk-cleanup, not a token problem) +
  --no-exclude-cache flag; README + CLAUDE.md rows (7 patterns, cache-aware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:17:55 +02:00

397 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan } from '../../scanners/token-hotspots.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
async function fixtureDiscovery(name) {
return discoverConfigFiles(resolve(FIXTURES, name));
}
async function runScanner(fixtureName) {
resetCounter();
const path = resolve(FIXTURES, fixtureName);
const discovery = await fixtureDiscovery(fixtureName);
// Hermetic HOME: scan() calls readActiveConfig, which resolves the user
// ~/.claude/CLAUDE.md cascade + plugins/MCP from process.env.HOME. Without
// this override the developer's real ~/.claude leaks into every fixture
// result — small-cascade tips past the 10k-token threshold and sonnet-era
// picks up ambient MCP findings — making assertions machine-dependent
// (green on a clean HOME, red on a populated one). Mirrors the OST test.
// NOTE: residual coupling — the cascade's ancestor walk still climbs from
// the fixture path through the real repo CLAUDE.md (an ancestor of tests/),
// but that contribution is deterministic per-checkout, not user-dependent.
return withHermeticHome(() => scan(path, discovery));
}
describe('TOK scanner — healthy-project', () => {
let result;
beforeEach(async () => {
result = await runScanner('healthy-project');
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix TOK', () => {
assert.equal(result.scanner, 'TOK');
});
it('finding IDs match CA-TOK-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-TOK-\d{3}$/);
}
});
it('exposes total_estimated_tokens as a number', () => {
assert.equal(typeof result.total_estimated_tokens, 'number');
assert.ok(result.total_estimated_tokens >= 0);
});
});
describe('TOK scanner — opus-47/cache-breaking', () => {
let result;
beforeEach(async () => {
result = await runScanner('opus-47/cache-breaking');
});
it('flags CA-TOK-001 (cache-breaking volatile top)', () => {
const f = result.findings.find(x => x.id === 'CA-TOK-001');
assert.ok(f, 'expected a CA-TOK-001 finding for cache-breaking fixture');
});
it('CA-TOK-001 severity is high (v5 F7 recalibration)', () => {
const f = result.findings.find(x => x.id === 'CA-TOK-001');
assert.equal(f.severity, 'high', `expected high after F7, got ${f.severity}`);
});
it('CA-TOK-001 description carries no stale Opus-4.7 anchor (mechanic is model-agnostic)', () => {
const f = result.findings.find(x => x.id === 'CA-TOK-001');
assert.doesNotMatch(f.description, /Opus 4\.7/,
'prompt-cache mechanics are a feature property, not model-specific; do not era-stamp a stale model');
});
});
describe('TOK scanner — opus-47/redundant-tools', () => {
let result;
beforeEach(async () => {
result = await runScanner('opus-47/redundant-tools');
});
it('emits at least one CA-TOK-002 finding (redundant tool/permission)', () => {
const has002 = result.findings.some(f => /^CA-TOK-002$/.test(f.id) || f.title?.toLowerCase().includes('redundant'));
assert.ok(has002, 'expected a CA-TOK-002 finding for redundant-tools fixture');
});
});
describe('TOK scanner — opus-47/deep-imports', () => {
let result;
beforeEach(async () => {
result = await runScanner('opus-47/deep-imports');
});
it('emits at least one CA-TOK-003 finding (deep @import chain)', () => {
const has003 = result.findings.some(f => /^CA-TOK-003$/.test(f.id) || f.title?.toLowerCase().includes('import'));
assert.ok(has003, 'expected a CA-TOK-003 finding for deep-imports fixture');
});
});
describe('TOK scanner — opus-47/sonnet-era (v5 F5: Pattern D removed)', () => {
let result;
beforeEach(async () => {
result = await runScanner('opus-47/sonnet-era');
});
it('emits zero findings (no Pattern D / CA-TOK-004 anymore)', () => {
assert.equal(result.findings.length, 0,
`expected 0 findings on sonnet-era after F5, got: ${result.findings.map(f => f.id).join(', ')}`);
});
it('never emits CA-TOK-004 (removed in v5)', () => {
assert.ok(result.findings.every(f => f.id !== 'CA-TOK-004'),
'expected no CA-TOK-004; removed in v5 F5');
});
});
describe('TOK scanner — marketplace scale ordering', () => {
it('total_estimated_tokens strictly increases across small → medium → large', async () => {
const small = await runScanner('marketplace-small');
const medium = await runScanner('marketplace-medium');
const large = await runScanner('marketplace-large');
assert.ok(small.total_estimated_tokens < medium.total_estimated_tokens,
`expected small (${small.total_estimated_tokens}) < medium (${medium.total_estimated_tokens})`);
assert.ok(medium.total_estimated_tokens < large.total_estimated_tokens,
`expected medium (${medium.total_estimated_tokens}) < large (${large.total_estimated_tokens})`);
});
});
describe('TOK scanner — readActiveConfig integration (v5 F1)', () => {
let result;
beforeEach(async () => {
result = await runScanner('tok-active-config');
});
it('exposes activeConfig summary on the result (proves readActiveConfig was called)', () => {
assert.ok(result.activeConfig, 'expected result.activeConfig to be set');
assert.equal(typeof result.activeConfig.claudeMdEstimatedTokens, 'number');
assert.ok(result.activeConfig.claudeMdEstimatedTokens > 0,
`expected claudeMd cascade > 0 tokens, got ${result.activeConfig.claudeMdEstimatedTokens}`);
});
it('hotspots include at least one MCP-source entry', () => {
const hasMcp = result.hotspots.some(h => /mcp/i.test(h.source));
assert.ok(hasMcp,
`expected hotspots to include an MCP source; got: ${result.hotspots.map(h => h.source).join(', ')}`);
});
it('total_estimated_tokens exceeds the minimal sonnet-era baseline', async () => {
// sonnet-era has no .mcp.json — the activeConfig MCP entries from this
// fixture should push its total above sonnet-era's even when both fixtures
// share the user's ambient cascade/plugin state.
const baseline = await runScanner('opus-47/sonnet-era');
assert.ok(result.total_estimated_tokens > baseline.total_estimated_tokens,
`expected ${result.total_estimated_tokens} > ${baseline.total_estimated_tokens}`);
});
});
describe('TOK scanner — hotspots contract', () => {
let result;
beforeEach(async () => {
result = await runScanner('marketplace-large');
});
it('every finding has a non-empty recommendation', () => {
for (const f of result.findings) {
assert.ok(f.recommendation, `finding ${f.id} missing recommendation`);
assert.ok(String(f.recommendation).length > 0, `finding ${f.id} has empty recommendation`);
}
});
it('exposes a hotspots array of length 310', () => {
assert.ok(Array.isArray(result.hotspots), 'expected result.hotspots to be an array');
assert.ok(result.hotspots.length >= 3, `expected ≥3 hotspots, got ${result.hotspots.length}`);
assert.ok(result.hotspots.length <= 10, `expected ≤10 hotspots, got ${result.hotspots.length}`);
});
it('every hotspot exposes source/estimated_tokens/rank/recommendations', () => {
for (const h of result.hotspots) {
assert.ok(typeof h.source === 'string' && h.source.length > 0, 'hotspot.source missing');
assert.equal(typeof h.estimated_tokens, 'number', 'hotspot.estimated_tokens not a number');
assert.equal(typeof h.rank, 'number', 'hotspot.rank not a number');
assert.ok(Array.isArray(h.recommendations), 'hotspot.recommendations not an array');
assert.ok(h.recommendations.length >= 1 && h.recommendations.length <= 3,
`hotspot.recommendations length should be 13, got ${h.recommendations.length}`);
}
});
it('every hotspot carries a load-pattern triple (v5.6 B2)', () => {
const LOAD_PATTERNS = new Set(['always', 'on-demand', 'external', 'unknown']);
const SURVIVES = new Set(['yes', 'no', 'n/a']);
for (const h of result.hotspots) {
assert.ok(LOAD_PATTERNS.has(h.loadPattern),
`hotspot ${h.source} has invalid loadPattern: ${h.loadPattern}`);
assert.ok(SURVIVES.has(h.survivesCompaction),
`hotspot ${h.source} has invalid survivesCompaction: ${h.survivesCompaction}`);
assert.ok(typeof h.derivationConfidence === 'string' && h.derivationConfidence.length > 0,
`hotspot ${h.source} missing derivationConfidence`);
}
});
it('tags a CLAUDE.md hotspot always-loaded and a skill hotspot on-demand (v5.6 B2)', () => {
const claudeMd = result.hotspots.find(h => /CLAUDE\.md/.test(h.source));
if (claudeMd) {
assert.equal(claudeMd.loadPattern, 'always',
`expected CLAUDE.md hotspot always-loaded; got ${claudeMd.loadPattern}`);
}
const skill = result.hotspots.find(h => /SKILL\.md/.test(h.source) || /skills?\//.test(h.source));
if (skill) {
assert.equal(skill.loadPattern, 'on-demand',
`expected skill hotspot on-demand; got ${skill.loadPattern}`);
}
});
it('every hotspot.source is unique (v5 F4: no padding)', () => {
const sources = result.hotspots.map(h => h.source);
const unique = new Set(sources);
assert.equal(unique.size, sources.length,
`expected unique sources; got duplicates in: ${sources.join(', ')}`);
});
it('hotspots.length never exceeds HOTSPOTS_MAX (10)', () => {
assert.ok(result.hotspots.length <= 10,
`expected ≤10 hotspots, got ${result.hotspots.length}`);
});
});
describe('TOK scanner — M2 skill description > 500 chars (v5)', () => {
it('flags skill with bloated description (low severity)', async () => {
const result = await runScanner('skill-bloated');
const f = result.findings.find(x => /skill description/i.test(x.title || ''));
assert.ok(f, `expected skill-description finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'low', `expected low, got ${f.severity}`);
assert.match(f.evidence || '', /bloated/);
});
it('does NOT flag tight description (under 500 chars)', async () => {
const result = await runScanner('skill-tight');
const f = result.findings.find(x => /skill description/i.test(x.title || ''));
assert.equal(f, undefined, `expected no skill-description finding; got: ${f?.title}`);
});
});
describe('TOK scanner — M4 cascade > 10k tokens (v5)', () => {
it('flags CLAUDE.md cascade > 10k tokens with medium severity', async () => {
const result = await runScanner('large-cascade');
const f = result.findings.find(x => /cascade/i.test(x.title || ''));
assert.ok(f, `expected cascade finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'medium', `expected medium, got ${f.severity}`);
assert.match(f.title, /CLAUDE\.md cascade/i);
});
it('does NOT flag small cascade (< 10k tokens)', async () => {
const result = await runScanner('small-cascade');
const f = result.findings.find(x => /cascade/i.test(x.title || ''));
assert.equal(f, undefined,
`expected no cascade finding for small fixture; got: ${f?.title}`);
});
});
describe('TOK scanner — N1 MCP tool-schema budget (v5 CA-TOK-005)', () => {
// readActiveConfig pulls in ambient ~/.claude.json plugin MCP servers; tests
// filter to the fixture's own server name (budget-srv-<count>) to avoid
// user-state leakage. Findings identified by title (not exact ID) — TOK IDs
// are sequential per scan.
const findFixtureBudget = (result, count) =>
result.findings.find(f =>
/MCP tool-schema budget/i.test(f.title || '') &&
(f.title || '').includes(`budget-srv-${count}`)
);
it('14 tools → no budget finding (under 20-tool floor)', async () => {
const result = await runScanner('mcp-budget/14-tools');
const f = findFixtureBudget(result, 14);
assert.equal(f, undefined,
`expected no budget finding for budget-srv-14 under 20 tools; got: ${f?.title}`);
});
it('25 tools → low severity', async () => {
const result = await runScanner('mcp-budget/25-tools');
const f = findFixtureBudget(result, 25);
assert.ok(f, `expected budget finding for budget-srv-25; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'low', `expected low for 25 tools, got ${f.severity}`);
});
it('60 tools → medium severity', async () => {
const result = await runScanner('mcp-budget/60-tools');
const f = findFixtureBudget(result, 60);
assert.ok(f, `expected budget finding for budget-srv-60`);
assert.equal(f.severity, 'medium', `expected medium for 60 tools, got ${f.severity}`);
});
it('120 tools → high severity', async () => {
const result = await runScanner('mcp-budget/120-tools');
const f = findFixtureBudget(result, 120);
assert.ok(f, `expected budget finding for budget-srv-120`);
assert.equal(f.severity, 'high', `expected high for 120 tools, got ${f.severity}`);
});
it('unknown toolCount → low severity with "unknown" in evidence', async () => {
const result = await runScanner('mcp-budget/unknown-tools');
const f = findFixtureBudget(result, 'unknown');
assert.ok(f, `expected budget finding for budget-srv-unknown`);
assert.equal(f.severity, 'low', `expected low for unknown toolCount, got ${f.severity}`);
assert.match(String(f.evidence || ''), /unknown/i,
`expected "unknown" in evidence, got: ${f.evidence}`);
});
it('finding ID matches CA-TOK-NNN format', async () => {
const result = await runScanner('mcp-budget/120-tools');
const f = findFixtureBudget(result, 120);
assert.ok(f);
assert.match(f.id, /^CA-TOK-\d{3}$/);
});
it('finding evidence carries calibration_note', async () => {
const result = await runScanner('mcp-budget/60-tools');
const f = findFixtureBudget(result, 60);
assert.ok(f);
assert.match(String(f.evidence || ''), /severity reflects estimated tokens\/turn/i);
});
});
describe('TOK scanner — F7 severity recalibration (v5)', () => {
// Findings identified by title pattern, not finding ID — TOK IDs are
// sequential per scan run, not semantic per pattern (output.mjs:31).
const SEVERITY_TABLE = [
{ fixture: 'opus-47/cache-breaking', pattern: 'A', titleMatch: /cache-breaking volatile/i, expected: 'high' },
{ fixture: 'opus-47/redundant-tools', pattern: 'B', titleMatch: /redundant permission/i, expected: 'medium' },
{ fixture: 'opus-47/deep-imports', pattern: 'C', titleMatch: /deep @import chain/i, expected: 'low' },
];
for (const { fixture, pattern, titleMatch, expected } of SEVERITY_TABLE) {
it(`Pattern ${pattern} (${fixture}) has severity ${expected}`, async () => {
const result = await runScanner(fixture);
const f = result.findings.find(x => titleMatch.test(x.title || ''));
assert.ok(f, `expected a finding matching ${titleMatch} in ${fixture}; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, expected, `expected ${expected}, got ${f.severity}`);
});
it(`Pattern ${pattern} (${fixture}) carries calibration_note evidence`, async () => {
const result = await runScanner(fixture);
const f = result.findings.find(x => titleMatch.test(x.title || ''));
assert.ok(f, `expected a finding matching ${titleMatch} in ${fixture}`);
const evidence = String(f.evidence || '');
assert.ok(/severity reflects estimated tokens\/turn/i.test(evidence),
`expected calibration_note phrase in evidence, got: ${evidence}`);
});
}
});
// ── Pattern H: stale plugin-cache versions (v5.9 B3) ──────────────────────
describe('TOK scanner — H stale plugin-cache versions (v5.9 B3)', () => {
const staleDiscovery = {
files: [],
staleCacheVersions: [
{ key: 'mkt/voyage/5.1.1', fileCount: 35, estimatedBytes: 416237 },
{ key: 'mkt/config-audit/5.6.0', fileCount: 149, estimatedBytes: 301330 },
],
};
it('emits a low-severity stale-cache finding when stale versions exist', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', staleDiscovery));
const f = result.findings.find(x => /stale plugin-cache/i.test(x.title || ''));
assert.ok(f, 'expected a stale plugin-cache finding');
assert.equal(f.severity, 'low');
assert.equal(f.category, 'plugin-cache-hygiene');
});
it('states zero live-context impact and lists the stale keys', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', staleDiscovery));
const f = result.findings.find(x => /stale plugin-cache/i.test(x.title || ''));
assert.match(f.evidence, /zero live-context impact/i);
assert.match(f.evidence, /mkt\/voyage\/5\.1\.1/);
assert.match(f.evidence, /mkt\/config-audit\/5\.6\.0/);
});
it('does NOT fire when there are no stale versions', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', { files: [], staleCacheVersions: [] }));
assert.ok(!result.findings.some(x => /stale plugin-cache/i.test(x.title || '')));
});
it('does NOT fire when discovery omits staleCacheVersions entirely', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', { files: [] }));
assert.ok(!result.findings.some(x => /stale plugin-cache/i.test(x.title || '')));
});
});