config-audit/tests/scanners/conflict-detector.test.mjs
Kjell Tore Guttormsen 7a794b47eb fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.

Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.

scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.

IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.

unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.

Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.

Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.

Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).

Suite 1535 -> 1573, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:26:36 +02:00

295 lines
12 KiB
JavaScript

import { describe, it, beforeEach, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
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 () => {
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 () => {
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 () => {
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 () => {
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 () => {
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') }],
},
}));
const dIncl = await discoverConfigFiles(dir);
includeCount = (await scan(dir, dIncl)).findings.filter(f => f.title.includes('Duplicate hook')).length;
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}`);
});
});
// M-BUG-2: CNF must segregate plugin-bundled configs from the user's authored
// cascade. Installed plugins ship their OWN settings.json / hooks.json — plus
// bundled test fixtures and examples — under ~/.claude/plugins/. None of these
// are user-authored config the user can edit, and a "conflict" between two
// plugins' bundled files is not something the user can resolve. Yet CNF compared
// every settings-json/hooks-json pairwise regardless of origin, inflating the
// Conflicts grade to F with hundreds of bogus findings (339 on this machine,
// ~315 of them high-severity permission "conflicts" between plugin test
// fixtures). CNF must exclude any file whose path is under `.claude/plugins/`.
// The exclusion belongs in CNF, NOT discovery: an active plugin's contributed
// hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners
// need it — only conflict analysis must ignore it.
describe('CNF scanner — excludes plugin-bundled configs (M-BUG-2)', () => {
let dir, result, discovery;
before(async () => {
dir = join(tmpdir(), `config-audit-cnf-mbug2-${Date.now()}`);
// Live, user-authored cascade (project-scope settings).
const liveClaude = join(dir, '.claude');
await mkdir(liveClaude, { recursive: true });
await writeFile(join(liveClaude, 'settings.json'), JSON.stringify({
model: 'opus',
permissions: { deny: ['Bash(curl:*)'] },
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
// An installed plugin ships its OWN settings.json + hooks.json (active version).
const p1 = join(liveClaude, 'plugins', 'cache', 'mkt', 'p1', '1.0.0');
await mkdir(join(p1, '.claude'), { recursive: true });
await writeFile(join(p1, '.claude', 'settings.json'), JSON.stringify({
model: 'sonnet',
permissions: { allow: ['Bash(curl:*)'] },
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
await mkdir(join(p1, 'hooks'), { recursive: true });
await writeFile(join(p1, 'hooks', 'hooks.json'), JSON.stringify({
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
// Another plugin ships a TEST FIXTURE settings.json — pure noise, never live.
const p2fix = join(liveClaude, 'plugins', 'cache', 'mkt', 'p2', '2.0.0',
'tests', 'fixtures', 'proj', '.claude');
await mkdir(p2fix, { recursive: true });
await writeFile(join(p2fix, 'settings.json'), JSON.stringify({
model: 'haiku',
permissions: { allow: ['Bash(rm:*)'] },
}));
discovery = await discoverConfigFiles(dir);
result = await scan(dir, discovery);
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('discovery surfaces the plugin-bundled settings (exclusion must be CNF-side, not discovery-side)', () => {
const pluginSettings = discovery.files.filter(
f => f.type === 'settings-json' && f.absPath.includes(`.claude${sep}plugins${sep}`));
assert.ok(pluginSettings.length >= 2,
`expected ≥2 plugin-bundled settings discovered; got ${pluginSettings.length}`);
});
it('does not flag any conflict sourced from plugin-bundled configs', () => {
assert.equal(result.findings.length, 0,
`expected 0 CNF findings (only noise is plugin-bundled); got ${result.findings.length}: ${result.findings.map(f => f.title).join(', ')}`);
});
});
// Guard against over-exclusion: the fix must NOT silence genuine conflicts
// between the user's own authored files (user/project/local), which are not
// under `.claude/plugins/`.
describe('CNF scanner — genuine live conflict still flagged (M-BUG-2 guard)', () => {
let dir, result;
before(async () => {
dir = join(tmpdir(), `config-audit-cnf-mbug2-guard-${Date.now()}`);
const claude = join(dir, '.claude');
await mkdir(claude, { recursive: true });
await writeFile(join(claude, 'settings.json'), JSON.stringify({ model: 'opus' }));
await writeFile(join(claude, 'settings.local.json'), JSON.stringify({ model: 'haiku' }));
const discovery = await discoverConfigFiles(dir);
result = await scan(dir, discovery);
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('still flags the genuine cross-scope key conflict between user-authored files', () => {
assert.ok(result.findings.some(f => f.title.includes('model')),
`expected a genuine "model" conflict; got: ${result.findings.map(f => f.title).join(', ') || '(none)'}`);
});
});