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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 18:17:55 +02:00
commit ba9f82f952
8 changed files with 124 additions and 6 deletions

View file

@ -95,6 +95,19 @@ test('humanizeFinding rewrites title for known static title', () => {
`humanized title should mention instructions or claude, got: ${out.title}`);
});
test('humanizeFinding: stale plugin-cache title humanizes to honest disk-cleanup prose, not the generic TOK default (B3)', () => {
const input = makeFinding({
scanner: 'TOK',
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
category: 'plugin-cache-hygiene',
});
const out = humanizeFinding(input);
assert.match(out.title, /disk/i, 'humanized title must name disk, not generic "space"');
assert.match(out.description, /zero tokens per turn|never loaded/i,
'humanized description must keep the zero-context-impact honesty');
assert.equal(out.userImpactCategory, 'Dead config');
});
test('humanizeFinding falls back to _default when title unknown', () => {
const input = makeFinding({ scanner: 'CML', title: 'Unrecognized brand-new finding title' });
const out = humanizeFinding(input);
@ -183,6 +196,14 @@ test('humanizeFinding sets category Dead config for DIS', () => {
assert.equal(out.userImpactCategory, 'Dead config');
});
test('humanizeFinding: per-finding category plugin-cache-hygiene overrides scanner default to Dead config (B3)', () => {
// TOK normally maps to "Wasted tokens", but a stale-cache finding loads on
// zero turns — its honest impact is "Dead config", not wasted per-turn tokens.
const out = humanizeFinding(makeFinding({ scanner: 'TOK', category: 'plugin-cache-hygiene' }));
assert.equal(out.userImpactCategory, 'Dead config');
assert.equal(out.category, 'plugin-cache-hygiene', 'raw category field preserved unchanged');
});
test('humanizeFinding sets category Missed opportunity for GAP', () => {
const out = humanizeFinding(makeFinding({ scanner: 'GAP', title: 'No CLAUDE.md file' }));
assert.equal(out.userImpactCategory, 'Missed opportunity');

View file

@ -354,3 +354,44 @@ describe('TOK scanner — F7 severity recalibration (v5)', () => {
});
}
});
// ── 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 || '')));
});
});