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

@ -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/<marketplace>/<plugin>/<version>/...
// 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 `<marketplace>/<plugin>/<version>` 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<Set<string> | 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 };
}
/**

View file

@ -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<object>} 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,
});

View file

@ -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 = {