From a37183268849c64c0af1b69a86e102195d5ace54 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 17:57:22 +0200 Subject: [PATCH] feat(discovery): version-aware cache filtering + --exclude-cache flag (v5.9 B3a) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scanners/lib/file-discovery.mjs | 147 +++++++++++++++++++++- scanners/scan-orchestrator.mjs | 12 +- scanners/token-hotspots-cli.mjs | 8 +- tests/lib/file-discovery.test.mjs | 120 ++++++++++++++++++ tests/scanners/conflict-detector.test.mjs | 56 ++++++++- 5 files changed, 335 insertions(+), 8 deletions(-) diff --git a/scanners/lib/file-discovery.mjs b/scanners/lib/file-discovery.mjs index f36dfb6..7f32bad 100644 --- a/scanners/lib/file-discovery.mjs +++ b/scanners/lib/file-discovery.mjs @@ -13,6 +13,141 @@ const SKIP_DIRS = new Set([ 'vendor', 'venv', '.venv', '.tox', ]); +// Path marker for the plugin install cache (~/.claude/plugins/cache). +// Structure: <...>/plugins/cache////... +// installed_plugins.json's installPath points INTO this tree at the ACTIVE +// version; any other version dir is a stale leftover (superseded install). +const PLUGIN_CACHE_MARKER = `plugins${sep}cache${sep}`; + +/** + * Extract the `//` key for a path inside + * ~/.claude/plugins/cache. Returns null when the path is not under + * plugins/cache, or is shallower than the version directory. + * @param {string} absPath + * @returns {string | null} + */ +export function cacheVersionKey(absPath) { + const i = absPath.indexOf(PLUGIN_CACHE_MARKER); + if (i === -1) return null; + const rest = absPath.slice(i + PLUGIN_CACHE_MARKER.length); + const segs = rest.split(sep).filter(Boolean); + if (segs.length < 3) return null; // need marketplace/plugin/version + return segs.slice(0, 3).join('/'); +} + +/** + * Given a path inside plugins/cache, return the absolute `<...>/plugins` + * directory that owns it (where installed_plugins.json lives). + * @param {string} absPath + * @returns {string | null} + */ +function pluginsDirForCachePath(absPath) { + const i = absPath.indexOf(PLUGIN_CACHE_MARKER); + if (i === -1) return null; + return absPath.slice(0, i + 'plugins'.length); +} + +/** + * Read the set of ACTIVE cache version-keys from a `<...>/plugins` directory's + * installed_plugins.json. Each record's installPath points at the version + * Claude Code loads. Returns null when the manifest is absent or unparseable — + * callers must then NOT filter (we cannot safely tell active from stale, and + * silently dropping active config is the worse failure). + * @param {string} pluginsDir + * @returns {Promise | null>} + */ +async function readActiveCacheVersions(pluginsDir) { + let raw; + try { + raw = await readFile(join(pluginsDir, 'installed_plugins.json'), 'utf-8'); + } catch { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const active = new Set(); + const plugins = parsed && parsed.plugins; + if (plugins && typeof plugins === 'object') { + for (const recs of Object.values(plugins)) { + if (!Array.isArray(recs)) continue; + for (const r of recs) { + const key = r && r.installPath ? cacheVersionKey(resolve(r.installPath)) : null; + if (key) active.add(key); + } + } + } + return active; +} + +/** + * Identify stale plugin-cache versions among discovered files and (optionally) + * filter them out. "Stale" = a cache version-key not referenced by the owning + * installed_plugins.json. Active versions are always kept — installPaths point + * INTO the cache, so a blunt "skip all of plugins/cache" would drop live config. + * + * @param {Array} files + * @param {boolean} excludeCache - when true, stale-version files are removed + * @returns {Promise<{ files: Array, staleCacheVersions: Array<{key:string, fileCount:number, estimatedBytes:number}> }>} + */ +async function applyCacheFilter(files, excludeCache) { + const cacheFiles = []; + for (const f of files) { + const key = cacheVersionKey(f.absPath); + if (key) cacheFiles.push({ f, key }); + } + if (cacheFiles.length === 0) return { files, staleCacheVersions: [] }; + + // Union active version-keys across every installed_plugins.json adjacent to + // the cache (a full-machine sweep only ever sees one, but be robust). + const pluginsDirs = new Set(); + for (const { f } of cacheFiles) { + const pd = pluginsDirForCachePath(f.absPath); + if (pd) pluginsDirs.add(pd); + } + let active = null; + for (const pd of pluginsDirs) { + const keys = await readActiveCacheVersions(pd); + if (keys) { + if (active === null) active = new Set(); + for (const k of keys) active.add(k); + } + } + + // No readable manifest → cannot distinguish active from stale → do nothing. + if (active === null) return { files, staleCacheVersions: [] }; + + const byKey = new Map(); + for (const { f, key } of cacheFiles) { + if (!byKey.has(key)) byKey.set(key, []); + byKey.get(key).push(f); + } + const staleCacheVersions = []; + for (const [key, group] of byKey) { + if (!active.has(key)) { + staleCacheVersions.push({ + key, + fileCount: group.length, + estimatedBytes: group.reduce((s, f) => s + (f.size || 0), 0), + }); + } + } + staleCacheVersions.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + + let outFiles = files; + if (excludeCache && staleCacheVersions.length > 0) { + const staleKeys = new Set(staleCacheVersions.map(s => s.key)); + outFiles = files.filter(f => { + const k = cacheVersionKey(f.absPath); + return k === null || !staleKeys.has(k); + }); + } + return { files: outFiles, staleCacheVersions }; +} + /** Config file patterns to discover */ const CONFIG_PATTERNS = { claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i, @@ -34,7 +169,8 @@ const CONFIG_PATTERNS = { * @param {object} [opts] * @param {number} [opts.maxFiles=500] - max files to return * @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/ - * @returns {Promise<{ files: ConfigFile[], skipped: number }>} + * @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3) + * @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>} * * @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile */ @@ -68,7 +204,8 @@ export async function discoverConfigFiles(targetPath, opts = {}) { } catch { /* doesn't exist */ } } - return { files, skipped: skippedRef.count }; + const { files: outFiles, staleCacheVersions } = await applyCacheFilter(files, opts.excludeCache || false); + return { files: outFiles, skipped: skippedRef.count, staleCacheVersions }; } /** @@ -245,7 +382,8 @@ export async function discoverFullMachinePaths() { * @param {Array<{ path: string, maxDepth: number }>} roots * @param {object} [opts] * @param {number} [opts.maxFiles=2000] - global max across all roots - * @returns {Promise<{ files: ConfigFile[], skipped: number }>} + * @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3) + * @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>} */ export async function discoverConfigFilesMulti(roots, opts = {}) { const maxFiles = opts.maxFiles || 2000; @@ -287,7 +425,8 @@ export async function discoverConfigFilesMulti(roots, opts = {}) { } catch { /* doesn't exist */ } } - return { files: allFiles, skipped: totalSkipped }; + const { files: outFiles, staleCacheVersions } = await applyCacheFilter(allFiles, opts.excludeCache || false); + return { files: outFiles, skipped: totalSkipped, staleCacheVersions }; } /** diff --git a/scanners/scan-orchestrator.mjs b/scanners/scan-orchestrator.mjs index b3e9929..096eb12 100644 --- a/scanners/scan-orchestrator.mjs +++ b/scanners/scan-orchestrator.mjs @@ -80,6 +80,7 @@ const SCANNERS = [ * @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine * @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore * @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths + * @param {boolean} [opts.excludeCache=true] - Drop stale ~/.claude/plugins/cache versions so findings reflect live config (B3) * @returns {Promise} Full envelope with all results */ // Exported for testing @@ -89,14 +90,19 @@ export async function runAllScanners(targetPath, opts = {}) { const start = Date.now(); const resolvedPath = resolve(targetPath); + // Default ON: stale cached plugin versions otherwise inflate token hotspots + // and CNF duplicate-hook findings with config that loads on zero turns. (B3) + const excludeCache = opts.excludeCache !== false; + // Shared file discovery — scanners reuse this let discovery; if (opts.fullMachine) { const roots = await discoverFullMachinePaths(); - discovery = await discoverConfigFilesMulti(roots); + discovery = await discoverConfigFilesMulti(roots, { excludeCache }); } else { discovery = await discoverConfigFiles(resolvedPath, { includeGlobal: opts.includeGlobal || false, + excludeCache, }); } @@ -216,6 +222,8 @@ async function main() { // handled below } else if (args[i] === '--include-fixtures') { // handled below + } else if (args[i] === '--exclude-cache' || args[i] === '--no-exclude-cache') { + // handled below } else if (args[i] === '--json') { // handled below — explicit machine-readable mode (bypass humanizer) } else if (args[i] === '--raw') { @@ -229,6 +237,7 @@ async function main() { const fullMachine = args.includes('--full-machine'); const suppress = !args.includes('--no-suppress'); const filterFixtures = !args.includes('--include-fixtures'); + const excludeCache = !args.includes('--no-exclude-cache'); const jsonMode = args.includes('--json'); const rawMode = args.includes('--raw'); @@ -243,6 +252,7 @@ async function main() { fullMachine, suppress, filterFixtures, + excludeCache, humanizedProgress, }); diff --git a/scanners/token-hotspots-cli.mjs b/scanners/token-hotspots-cli.mjs index ff0c542..d09ab2c 100755 --- a/scanners/token-hotspots-cli.mjs +++ b/scanners/token-hotspots-cli.mjs @@ -56,6 +56,10 @@ async function main() { let includeGlobal = false; let withTelemetryRecipe = false; let accurateTokens = false; + // Default ON for this live-cost scan: stale plugin-cache versions pollute the + // hotspot ranking with config that loads on zero turns. --no-exclude-cache + // restores the full walk. (B3) + let excludeCache = true; for (let i = 0; i < args.length; i++) { if (args[i] === '--json') jsonMode = true; @@ -63,6 +67,8 @@ async function main() { else if (args[i] === '--global') includeGlobal = true; else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true; else if (args[i] === '--accurate-tokens') accurateTokens = true; + else if (args[i] === '--exclude-cache') excludeCache = true; + else if (args[i] === '--no-exclude-cache') excludeCache = false; else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i]; else if (!args[i].startsWith('-')) targetPath = args[i]; } @@ -80,7 +86,7 @@ async function main() { } resetCounter(); - const discovery = await discoverConfigFiles(absPath, { includeGlobal }); + const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache }); const result = await scan(absPath, discovery); const payload = { diff --git a/tests/lib/file-discovery.test.mjs b/tests/lib/file-discovery.test.mjs index cd2ac74..036fe4f 100644 --- a/tests/lib/file-discovery.test.mjs +++ b/tests/lib/file-discovery.test.mjs @@ -8,6 +8,7 @@ import { discoverConfigFilesMulti, discoverFullMachinePaths, readTextFile, + cacheVersionKey, } from '../../scanners/lib/file-discovery.mjs'; /** @@ -17,6 +18,39 @@ function tempDir(suffix) { return join(tmpdir(), `config-audit-fd-test-${suffix}-${Date.now()}`); } +/** + * Build a realistic ~/.claude/plugins/cache fixture under `root`. + * `versions` is a list of 'marketplace/plugin/version' keys to materialize on + * disk (each gets a CLAUDE.md + hooks/hooks.json). `active` is the subset of + * those keys that installed_plugins.json references via installPath — i.e. the + * versions Claude Code actually loads. Versions NOT in `active` are stale. + */ +async function buildCacheFixture(root, { versions, active }) { + const pluginsDir = join(root, 'plugins'); + for (const key of versions) { + const verDir = join(pluginsDir, 'cache', ...key.split('/')); + await mkdir(join(verDir, 'hooks'), { recursive: true }); + await writeFile(join(verDir, 'CLAUDE.md'), `# ${key}`); + await writeFile( + join(verDir, 'hooks', 'hooks.json'), + JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit' }] } }) + ); + } + const plugins = {}; + for (const key of active) { + const [mkt, plug, version] = key.split('/'); + plugins[`${plug}@${mkt}`] = [{ + scope: 'user', + installPath: join(pluginsDir, 'cache', mkt, plug, version), + version, + }]; + } + await writeFile( + join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins }) + ); +} + // ─────────────────────────────────────────────────────────────── // Group 1: discoverConfigFiles — single path // ─────────────────────────────────────────────────────────────── @@ -389,3 +423,89 @@ describe('readTextFile', () => { assert.equal(content, null); }); }); + +// ─────────────────────────────────────────────────────────────── +// Group 7: cache-aware filtering (B3) — version-aware exclusion of +// stale ~/.claude/plugins/cache versions; active versions kept. +// ─────────────────────────────────────────────────────────────── + +describe('cacheVersionKey (pure)', () => { + it('extracts marketplace/plugin/version from a plugins/cache path', () => { + const p = '/home/u/.claude/plugins/cache/mkt/voyage/5.6.0/commands/x.md'; + assert.equal(cacheVersionKey(p), 'mkt/voyage/5.6.0'); + }); + + it('works for the version directory itself', () => { + assert.equal(cacheVersionKey('/x/plugins/cache/m/p/1.0.0'), 'm/p/1.0.0'); + }); + + it('returns null for paths outside plugins/cache', () => { + assert.equal(cacheVersionKey('/home/u/repos/proj/cache/foo/bar/baz'), null); + assert.equal(cacheVersionKey('/home/u/.claude/agents/x.md'), null); + }); + + it('returns null when shallower than version level', () => { + assert.equal(cacheVersionKey('/x/plugins/cache/m/p'), null); + }); +}); + +describe('discoverConfigFiles — cache-aware filtering', () => { + let dir; + const ACTIVE = 'mkt/voyage/5.6.0'; + const STALE1 = 'mkt/voyage/5.1.1'; + const STALE2 = 'mkt/config-audit/5.1.0'; + + before(async () => { + dir = tempDir('cache'); + await buildCacheFixture(dir, { + versions: [ACTIVE, STALE1, STALE2], + active: [ACTIVE], + }); + }); + + after(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('default (no excludeCache): keeps every cached version', async () => { + const { files } = await discoverConfigFiles(dir); + const keys = new Set(files.map(f => cacheVersionKey(f.absPath)).filter(Boolean)); + assert.ok(keys.has(ACTIVE) && keys.has(STALE1) && keys.has(STALE2), + 'all version keys present without excludeCache'); + }); + + it('excludeCache: drops stale versions, keeps active', async () => { + const { files } = await discoverConfigFiles(dir, { excludeCache: true }); + const keys = new Set(files.map(f => cacheVersionKey(f.absPath)).filter(Boolean)); + assert.ok(keys.has(ACTIVE), 'active version retained'); + assert.ok(!keys.has(STALE1), 'stale version 1 dropped'); + assert.ok(!keys.has(STALE2), 'stale version 2 dropped'); + }); + + it('reports staleCacheVersions regardless of excludeCache', async () => { + const { staleCacheVersions } = await discoverConfigFiles(dir, { excludeCache: true }); + const staleKeys = staleCacheVersions.map(s => s.key).sort(); + assert.deepEqual(staleKeys, [STALE2, STALE1].sort()); + const s1 = staleCacheVersions.find(s => s.key === STALE1); + assert.ok(s1.fileCount >= 2, 'counts the cached config files (CLAUDE.md + hooks.json)'); + }); + + it('does NOT filter when installed_plugins.json is absent (cannot tell active from stale)', async () => { + const noManifest = tempDir('cache-nomanifest'); + // materialize cache versions but NO installed_plugins.json + await buildCacheFixture(noManifest, { versions: [ACTIVE, STALE1], active: [] }); + await rm(join(noManifest, 'plugins', 'installed_plugins.json'), { force: true }); + const { files, staleCacheVersions } = await discoverConfigFiles(noManifest, { excludeCache: true }); + const keys = new Set(files.map(f => cacheVersionKey(f.absPath)).filter(Boolean)); + assert.ok(keys.has(ACTIVE) && keys.has(STALE1), 'no filtering without a manifest'); + assert.equal(staleCacheVersions.length, 0, 'no stale claims without a manifest'); + await rm(noManifest, { recursive: true, force: true }); + }); + + it('discoverConfigFilesMulti applies the same cache filter', async () => { + const { files } = await discoverConfigFilesMulti([{ path: dir, maxDepth: 10 }], { excludeCache: true }); + const keys = new Set(files.map(f => cacheVersionKey(f.absPath)).filter(Boolean)); + assert.ok(keys.has(ACTIVE), 'active retained via Multi'); + assert.ok(!keys.has(STALE1) && !keys.has(STALE2), 'stale dropped via Multi'); + }); +}); diff --git a/tests/scanners/conflict-detector.test.mjs b/tests/scanners/conflict-detector.test.mjs index 7ab59d5..f0c3226 100644 --- a/tests/scanners/conflict-detector.test.mjs +++ b/tests/scanners/conflict-detector.test.mjs @@ -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}`); + }); +});