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:
parent
a17823a9af
commit
a371832688
5 changed files with 335 additions and 8 deletions
|
|
@ -13,6 +13,141 @@ const SKIP_DIRS = new Set([
|
||||||
'vendor', 'venv', '.venv', '.tox',
|
'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 */
|
/** Config file patterns to discover */
|
||||||
const CONFIG_PATTERNS = {
|
const CONFIG_PATTERNS = {
|
||||||
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
||||||
|
|
@ -34,7 +169,8 @@ const CONFIG_PATTERNS = {
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {number} [opts.maxFiles=500] - max files to return
|
* @param {number} [opts.maxFiles=500] - max files to return
|
||||||
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
* @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
|
* @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 */ }
|
} 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 {Array<{ path: string, maxDepth: number }>} roots
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
* @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 = {}) {
|
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||||
const maxFiles = opts.maxFiles || 2000;
|
const maxFiles = opts.maxFiles || 2000;
|
||||||
|
|
@ -287,7 +425,8 @@ export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||||
} catch { /* doesn't exist */ }
|
} 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ const SCANNERS = [
|
||||||
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
* @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.suppress=true] - Apply suppressions from .config-audit-ignore
|
||||||
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
|
* @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
|
* @returns {Promise<object>} Full envelope with all results
|
||||||
*/
|
*/
|
||||||
// Exported for testing
|
// Exported for testing
|
||||||
|
|
@ -89,14 +90,19 @@ export async function runAllScanners(targetPath, opts = {}) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const resolvedPath = resolve(targetPath);
|
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
|
// Shared file discovery — scanners reuse this
|
||||||
let discovery;
|
let discovery;
|
||||||
if (opts.fullMachine) {
|
if (opts.fullMachine) {
|
||||||
const roots = await discoverFullMachinePaths();
|
const roots = await discoverFullMachinePaths();
|
||||||
discovery = await discoverConfigFilesMulti(roots);
|
discovery = await discoverConfigFilesMulti(roots, { excludeCache });
|
||||||
} else {
|
} else {
|
||||||
discovery = await discoverConfigFiles(resolvedPath, {
|
discovery = await discoverConfigFiles(resolvedPath, {
|
||||||
includeGlobal: opts.includeGlobal || false,
|
includeGlobal: opts.includeGlobal || false,
|
||||||
|
excludeCache,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,6 +222,8 @@ async function main() {
|
||||||
// handled below
|
// handled below
|
||||||
} else if (args[i] === '--include-fixtures') {
|
} else if (args[i] === '--include-fixtures') {
|
||||||
// handled below
|
// handled below
|
||||||
|
} else if (args[i] === '--exclude-cache' || args[i] === '--no-exclude-cache') {
|
||||||
|
// handled below
|
||||||
} else if (args[i] === '--json') {
|
} else if (args[i] === '--json') {
|
||||||
// handled below — explicit machine-readable mode (bypass humanizer)
|
// handled below — explicit machine-readable mode (bypass humanizer)
|
||||||
} else if (args[i] === '--raw') {
|
} else if (args[i] === '--raw') {
|
||||||
|
|
@ -229,6 +237,7 @@ async function main() {
|
||||||
const fullMachine = args.includes('--full-machine');
|
const fullMachine = args.includes('--full-machine');
|
||||||
const suppress = !args.includes('--no-suppress');
|
const suppress = !args.includes('--no-suppress');
|
||||||
const filterFixtures = !args.includes('--include-fixtures');
|
const filterFixtures = !args.includes('--include-fixtures');
|
||||||
|
const excludeCache = !args.includes('--no-exclude-cache');
|
||||||
const jsonMode = args.includes('--json');
|
const jsonMode = args.includes('--json');
|
||||||
const rawMode = args.includes('--raw');
|
const rawMode = args.includes('--raw');
|
||||||
|
|
||||||
|
|
@ -243,6 +252,7 @@ async function main() {
|
||||||
fullMachine,
|
fullMachine,
|
||||||
suppress,
|
suppress,
|
||||||
filterFixtures,
|
filterFixtures,
|
||||||
|
excludeCache,
|
||||||
humanizedProgress,
|
humanizedProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ async function main() {
|
||||||
let includeGlobal = false;
|
let includeGlobal = false;
|
||||||
let withTelemetryRecipe = false;
|
let withTelemetryRecipe = false;
|
||||||
let accurateTokens = 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++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i] === '--json') jsonMode = true;
|
if (args[i] === '--json') jsonMode = true;
|
||||||
|
|
@ -63,6 +67,8 @@ async function main() {
|
||||||
else if (args[i] === '--global') includeGlobal = true;
|
else if (args[i] === '--global') includeGlobal = true;
|
||||||
else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true;
|
else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true;
|
||||||
else if (args[i] === '--accurate-tokens') accurateTokens = 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] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +86,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCounter();
|
resetCounter();
|
||||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
|
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
|
||||||
const result = await scan(absPath, discovery);
|
const result = await scan(absPath, discovery);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
discoverConfigFilesMulti,
|
discoverConfigFilesMulti,
|
||||||
discoverFullMachinePaths,
|
discoverFullMachinePaths,
|
||||||
readTextFile,
|
readTextFile,
|
||||||
|
cacheVersionKey,
|
||||||
} from '../../scanners/lib/file-discovery.mjs';
|
} from '../../scanners/lib/file-discovery.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,6 +18,39 @@ function tempDir(suffix) {
|
||||||
return join(tmpdir(), `config-audit-fd-test-${suffix}-${Date.now()}`);
|
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
|
// Group 1: discoverConfigFiles — single path
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -389,3 +423,89 @@ describe('readTextFile', () => {
|
||||||
assert.equal(content, null);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 assert from 'node:assert/strict';
|
||||||
import { resolve } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 { resetCounter } from '../../scanners/lib/output.mjs';
|
||||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||||
import { scan } from '../../scanners/conflict-detector.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);
|
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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue