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>
210 lines
7.5 KiB
JavaScript
210 lines
7.5 KiB
JavaScript
import { describe, it, beforeEach, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
describe('CNF scanner — conflict project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project'));
|
|
result = await scan(resolve(FIXTURES, 'conflict-project'), discovery);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('reports scanner prefix CNF', () => {
|
|
assert.equal(result.scanner, 'CNF');
|
|
});
|
|
|
|
it('finding IDs match CA-CNF-NNN pattern', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-CNF-\d{3}$/);
|
|
}
|
|
});
|
|
|
|
it('detects model key conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('model')));
|
|
});
|
|
|
|
it('settings conflict is medium severity', () => {
|
|
const model = result.findings.find(f => f.title.includes('model'));
|
|
assert.equal(model.severity, 'medium');
|
|
});
|
|
|
|
it('detects effortLevel key conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('effortLevel')));
|
|
});
|
|
|
|
it('detects permission allow/deny conflict', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny')));
|
|
});
|
|
|
|
it('permission conflict is high severity', () => {
|
|
const perm = result.findings.find(f => f.title.includes('Permission allow/deny'));
|
|
assert.equal(perm.severity, 'high');
|
|
});
|
|
|
|
it('detects duplicate hook definition', () => {
|
|
assert.ok(result.findings.some(f => f.title.includes('Duplicate hook')));
|
|
});
|
|
|
|
it('duplicate hook is low severity', () => {
|
|
const hook = result.findings.find(f => f.title.includes('Duplicate hook'));
|
|
assert.equal(hook.severity, 'low');
|
|
});
|
|
|
|
it('has exactly 4 findings', () => {
|
|
assert.equal(result.findings.length, 4);
|
|
});
|
|
|
|
it('includes evidence with scope info', () => {
|
|
const perm = result.findings.find(f => f.title.includes('Permission'));
|
|
assert.ok(perm.evidence);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — healthy project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
|
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
|
});
|
|
|
|
it('returns ok with no conflicts', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.equal(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
|
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
|
});
|
|
|
|
it('returns skipped when no config files', () => {
|
|
assert.equal(result.status, 'skipped');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.equal(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — minimal project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
|
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
|
});
|
|
|
|
it('returns skipped with no settings files', () => {
|
|
assert.equal(result.status, 'skipped');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.equal(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('CNF scanner — param-qualified cross-scope conflicts', () => {
|
|
// project allow: WebFetch(domain:good.com), Agent(model:sonnet)
|
|
// local deny: WebFetch(domain:*), Agent(model:opus)
|
|
// WebFetch: deny domain:* covers allow domain:good.com → genuine conflict.
|
|
// Agent: deny model:opus vs allow model:sonnet → disjoint → NO conflict.
|
|
let result;
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'param-conflict-project'));
|
|
result = await scan(resolve(FIXTURES, 'param-conflict-project'), discovery);
|
|
});
|
|
|
|
it('detects the WebFetch wildcard-domain conflict (currently a false negative)', () => {
|
|
const perm = result.findings.filter(f => f.title.includes('Permission allow/deny'));
|
|
assert.ok(
|
|
perm.some(f => /WebFetch/.test(`${f.description} ${f.evidence}`)),
|
|
`expected a WebFetch allow/deny conflict; got: ${perm.map(f => f.evidence).join(' | ') || '(none)'}`,
|
|
);
|
|
});
|
|
|
|
it('does NOT flag Agent(model:sonnet) vs Agent(model:opus) as a conflict', () => {
|
|
const perm = result.findings.filter(f => f.title.includes('Permission allow/deny'));
|
|
assert.ok(
|
|
!perm.some(f => /Agent/.test(`${f.description} ${f.evidence}`)),
|
|
'distinct model params must not conflict',
|
|
);
|
|
});
|
|
|
|
it('reports exactly one permission conflict', () => {
|
|
const perm = result.findings.filter(f => f.title.includes('Permission allow/deny'));
|
|
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}`);
|
|
});
|
|
});
|