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
244 lines
8.7 KiB
JavaScript
244 lines
8.7 KiB
JavaScript
/**
|
|
* CNF Scanner — Conflict Detector
|
|
* Detects conflicts between config files at different hierarchy levels:
|
|
* settings key conflicts, permission contradictions, hook duplicates.
|
|
* Finding IDs: CA-CNF-NNN
|
|
*/
|
|
|
|
import { sep } from 'node:path';
|
|
import { readTextFile } from './lib/file-discovery.mjs';
|
|
import { finding, scannerResult } from './lib/output.mjs';
|
|
import { SEVERITY } from './lib/severity.mjs';
|
|
import { parseJson } from './lib/yaml-parser.mjs';
|
|
import { truncate } from './lib/string-utils.mjs';
|
|
import { rulesIntersect } from './lib/permission-rules.mjs';
|
|
|
|
const SCANNER = 'CNF';
|
|
|
|
// Keys checked separately or not meaningful to compare
|
|
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
|
|
|
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
|
|
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
|
|
// not the user's authored cascade and a "conflict" between them is not something
|
|
// the user can resolve, so they must be excluded from cross-scope conflict
|
|
// analysis. (Other scanners still need active plugin config, so this exclusion is
|
|
// CNF-local, not a discovery-level skip. M-BUG-2.)
|
|
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
|
|
|
/**
|
|
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
|
* @returns {boolean} true if the file is shipped by an installed plugin
|
|
*/
|
|
function isPluginBundled(file) {
|
|
return file.absPath.includes(PLUGIN_TREE_MARKER);
|
|
}
|
|
|
|
/**
|
|
* Flatten an object's top-level keys into a simple key→value map.
|
|
* Only first level — we compare top-level settings, not nested.
|
|
* @param {object} obj
|
|
* @returns {Map<string, string>} key → JSON-stringified value
|
|
*/
|
|
function flattenTopLevel(obj) {
|
|
const map = new Map();
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (!SKIP_KEYS.has(key)) {
|
|
map.set(key, JSON.stringify(value));
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Collect hooks from a parsed settings or hooks.json object.
|
|
* @param {object} parsed
|
|
* @returns {{ event: string, matcher: string }[]}
|
|
*/
|
|
function collectHooks(parsed) {
|
|
const hooks = parsed.hooks || parsed;
|
|
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
|
|
|
|
const result = [];
|
|
for (const [event, handlers] of Object.entries(hooks)) {
|
|
if (!Array.isArray(handlers)) continue;
|
|
for (const handler of handlers) {
|
|
const matcher = typeof handler.matcher === 'string' ? handler.matcher : '*';
|
|
result.push({ event, matcher });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Scan for conflicts across configuration scopes.
|
|
* @param {string} targetPath
|
|
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
|
* @returns {Promise<object>}
|
|
*/
|
|
export async function scan(targetPath, discovery) {
|
|
const start = Date.now();
|
|
const findings = [];
|
|
|
|
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
|
|
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
|
|
// Collect hooks files (excluding plugin-bundled)
|
|
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
|
|
|
|
const totalFiles = settingsFiles.length + hooksFiles.length;
|
|
|
|
// Need at least 2 files to detect conflicts
|
|
if (settingsFiles.length < 2 && (settingsFiles.length + hooksFiles.length) < 2) {
|
|
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
|
}
|
|
|
|
// --- Settings key conflicts ---
|
|
const settingsByScope = []; // [{ scope, file, keys: Map<key, jsonValue> }]
|
|
|
|
for (const file of settingsFiles) {
|
|
const content = await readTextFile(file.absPath);
|
|
if (!content) continue;
|
|
const parsed = parseJson(content);
|
|
if (!parsed) continue;
|
|
settingsByScope.push({
|
|
scope: file.scope,
|
|
file: file.relPath,
|
|
absPath: file.absPath,
|
|
keys: flattenTopLevel(parsed),
|
|
raw: parsed,
|
|
});
|
|
}
|
|
|
|
// Compare keys across scopes
|
|
if (settingsByScope.length >= 2) {
|
|
const allKeys = new Set();
|
|
for (const s of settingsByScope) {
|
|
for (const key of s.keys.keys()) allKeys.add(key);
|
|
}
|
|
|
|
for (const key of allKeys) {
|
|
const scopesWithKey = settingsByScope.filter(s => s.keys.has(key));
|
|
if (scopesWithKey.length < 2) continue;
|
|
|
|
// Check if values differ
|
|
const values = new Set(scopesWithKey.map(s => s.keys.get(key)));
|
|
if (values.size > 1) {
|
|
const details = scopesWithKey
|
|
.map(s => `${s.scope} (${s.file}): ${truncate(s.keys.get(key), 40)}`)
|
|
.join('; ');
|
|
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'settings-key-conflict',
|
|
severity: SEVERITY.medium,
|
|
title: `Settings key conflict: "${key}"`,
|
|
description: `Key "${key}" has different values across scopes. ${details}`,
|
|
file: scopesWithKey[0].absPath,
|
|
evidence: details,
|
|
recommendation: `Verify the "${key}" value is intentionally different across scopes. The most specific scope wins (local > project > user).`,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Permission conflicts ---
|
|
for (let i = 0; i < settingsByScope.length; i++) {
|
|
for (let j = i + 1; j < settingsByScope.length; j++) {
|
|
const a = settingsByScope[i];
|
|
const b = settingsByScope[j];
|
|
|
|
const aPerms = a.raw.permissions || {};
|
|
const bPerms = b.raw.permissions || {};
|
|
|
|
const aAllow = Array.isArray(aPerms.allow) ? aPerms.allow : [];
|
|
const aDeny = Array.isArray(aPerms.deny) ? aPerms.deny : [];
|
|
const bAllow = Array.isArray(bPerms.allow) ? bPerms.allow : [];
|
|
const bDeny = Array.isArray(bPerms.deny) ? bPerms.deny : [];
|
|
|
|
// Check: allow in A, deny in B (and vice versa)
|
|
for (const allowRule of aAllow) {
|
|
for (const denyRule of bDeny) {
|
|
if (rulesIntersect(allowRule, denyRule)) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'permission-allow-deny',
|
|
severity: SEVERITY.high,
|
|
title: 'Permission allow/deny conflict',
|
|
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
|
|
file: a.absPath,
|
|
evidence: `allow: "${allowRule}" (${a.scope}) vs deny: "${denyRule}" (${b.scope})`,
|
|
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reverse: allow in B, deny in A
|
|
for (const allowRule of bAllow) {
|
|
for (const denyRule of aDeny) {
|
|
if (rulesIntersect(allowRule, denyRule)) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'permission-allow-deny',
|
|
severity: SEVERITY.high,
|
|
title: 'Permission allow/deny conflict',
|
|
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
|
|
file: b.absPath,
|
|
evidence: `allow: "${allowRule}" (${b.scope}) vs deny: "${denyRule}" (${a.scope})`,
|
|
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Hook duplicates (across settings + hooks.json files) ---
|
|
const hookSources = []; // [{ event, matcher, source }]
|
|
|
|
for (const s of settingsByScope) {
|
|
if (s.raw.hooks) {
|
|
for (const h of collectHooks(s.raw)) {
|
|
hookSources.push({ ...h, source: `${s.scope}:${s.file}` });
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const file of hooksFiles) {
|
|
const content = await readTextFile(file.absPath);
|
|
if (!content) continue;
|
|
const parsed = parseJson(content);
|
|
if (!parsed) continue;
|
|
const hookData = parsed.hooks || parsed;
|
|
for (const h of collectHooks(hookData)) {
|
|
hookSources.push({ ...h, source: `hooks:${file.relPath}` });
|
|
}
|
|
}
|
|
|
|
// Group by event:matcher
|
|
const hookGroups = new Map();
|
|
for (const h of hookSources) {
|
|
const key = `${h.event}:${h.matcher}`;
|
|
if (!hookGroups.has(key)) hookGroups.set(key, []);
|
|
hookGroups.get(key).push(h.source);
|
|
}
|
|
|
|
for (const [key, sources] of hookGroups) {
|
|
// Only flag duplicates from DIFFERENT sources
|
|
const uniqueSources = [...new Set(sources)];
|
|
if (uniqueSources.length >= 2) {
|
|
const [event, matcher] = key.split(':');
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'duplicate-hook',
|
|
severity: SEVERITY.low,
|
|
title: 'Duplicate hook definition',
|
|
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
|
|
evidence: uniqueSources.join(', '),
|
|
recommendation: 'Consolidate hook definitions to avoid unexpected execution order.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
return scannerResult(SCANNER, 'ok', findings, totalFiles, Date.now() - start);
|
|
}
|