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>
397 lines
18 KiB
JavaScript
397 lines
18 KiB
JavaScript
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 3–10', () => {
|
||
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 1–3, 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 || '')));
|
||
});
|
||
});
|