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
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue