feat(discovery): version-aware cache filtering + --exclude-cache flag (v5.9 B3a) [skip-docs]

Stale ~/.claude/plugins/cache versions polluted token-hotspots ranking and
inflated CNF duplicate-hook findings with config that loads on zero turns.
installPaths point INTO the cache, so a blunt "skip all of plugins/cache"
would drop ACTIVE plugins — the filter is therefore version-aware: it reads
the adjacent installed_plugins.json, keeps active version dirs, drops only
stale ones (and exposes them via discovery.staleCacheVersions for B3b).

- file-discovery: cacheVersionKey() + applyCacheFilter() (active vs stale via
  installed_plugins.json; HOME-independent, derives the manifest from the cache
  path); discoverConfigFiles/Multi gain { excludeCache } + staleCacheVersions.
  Absent/unparseable manifest -> no filtering (never silently drop live config).
- token-hotspots-cli + scan-orchestrator: --exclude-cache (default ON for these
  live-cost scans) / --no-exclude-cache restores the full walk.
- Tests: cacheVersionKey unit cases; stale-dropped/active-kept/no-manifest
  discovery cases; CNF-drop proof (soft-spot verified, not assumed:
  include=1 -> exclude=0 duplicate-hook findings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 17:57:22 +02:00
commit a371832688
5 changed files with 335 additions and 8 deletions

View file

@ -1,7 +1,9 @@
import { describe, it, beforeEach } from 'node:test';
import { describe, it, beforeEach, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/conflict-detector.mjs';
@ -156,3 +158,53 @@ describe('CNF scanner — param-qualified cross-scope conflicts', () => {
assert.equal(perm.length, 1);
});
});
// B3 soft-spot proof: stale ~/.claude/plugins/cache versions ship hooks.json
// that the SAME plugin also ships in its active version. CNF groups hooks by
// event:matcher across sources, so multiple cached versions inflate the
// "Duplicate hook" count. Excluding stale cache must measurably DROP CNF
// findings. This verifies the mechanism rather than assuming it.
describe('CNF scanner — cache exclusion drops duplicate-hook count (B3)', () => {
let dir, includeCount, excludeCount;
before(async () => {
dir = join(tmpdir(), `config-audit-cnf-cache-${Date.now()}`);
const pluginsDir = join(dir, 'plugins');
const versions = ['mkt/voyage/5.6.0', 'mkt/voyage/5.1.1', 'mkt/voyage/5.0.0'];
for (const key of versions) {
const verDir = join(pluginsDir, 'cache', ...key.split('/'), 'hooks');
await mkdir(verDir, { recursive: true });
await writeFile(join(verDir, 'hooks.json'),
JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit' }] } }));
}
// Only 5.6.0 is active; 5.1.1 + 5.0.0 are stale.
await writeFile(join(pluginsDir, 'installed_plugins.json'), JSON.stringify({
version: 2,
plugins: {
'voyage@mkt': [{ scope: 'user', version: '5.6.0',
installPath: join(pluginsDir, 'cache', 'mkt', 'voyage', '5.6.0') }],
},
}));
resetCounter();
const dIncl = await discoverConfigFiles(dir);
includeCount = (await scan(dir, dIncl)).findings.filter(f => f.title.includes('Duplicate hook')).length;
resetCounter();
const dExcl = await discoverConfigFiles(dir, { excludeCache: true });
excludeCount = (await scan(dir, dExcl)).findings.filter(f => f.title.includes('Duplicate hook')).length;
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('full walk surfaces ≥1 duplicate-hook finding from cached versions', () => {
assert.ok(includeCount >= 1, `expected duplicate hooks with cache, got ${includeCount}`);
});
it('excluding cache measurably drops the duplicate-hook count', () => {
assert.ok(excludeCount < includeCount,
`cache exclusion must drop CNF count: include=${includeCount} exclude=${excludeCount}`);
});
});