config-audit/scanners/lib/file-discovery.mjs
Kjell Tore Guttormsen bfd577aeee fix(acr): file-discovery skips backups/ dirs — never live config (M-BUG-8)
A directory named `backups` holds backup COPIES, not live config, so walking
it during a config audit produces stale findings. config-audit's own session
backups (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) were the
canonical case: a ~/.claude-scope audit walked 36 frozen config copies as if
live, polluting CPS (C3) and HKV/RUL (C6) results. Same non-live-noise family
as M-BUG-2.

- Add `backups` to SKIP_DIRS (broad, name-based — consistent with vendor/dist/
  .cache): the rule is general, a backups/ dir is never live config.

TDD: 2 failing tests (no config under backups/ discovered + backups/ counted
as skipped) -> green; a third asserts live config beside the backups tree is
still discovered. Full suite 1310/0 (+3). Byte-stable: no fixture is named
`backups` and `backups` appears in zero frozen snapshots, so v5.0.0 + SC-5 +
default-output outputs are unchanged. Dogfooding ~/.claude: files under
/backups/ drop from 36 -> 0; 717 live config files retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 12:23:43 +02:00

452 lines
15 KiB
JavaScript

/**
* Config file discovery for config-audit.
* Finds CLAUDE.md, settings.json, hooks.json, .mcp.json, rules/, plugin.json, etc.
* Zero external dependencies.
*/
import { readdir, stat, readFile } from 'node:fs/promises';
import { join, resolve, relative, extname, basename, dirname, sep } from 'node:path';
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
'vendor', 'venv', '.venv', '.tox',
// A `backups` dir holds backup COPIES, not live config — auditing it as if
// live produces stale findings. config-audit's own session backups
// (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) are the canonical
// case (M-BUG-8), but the rule is general: backups are never live config.
'backups',
]);
// 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 */
const CONFIG_PATTERNS = {
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
settingsJson: /^settings\.json$|^settings\.local\.json$/,
mcpJson: /^\.mcp\.json$/,
pluginJson: /^plugin\.json$/,
hooksJson: /^hooks\.json$/,
rulesDir: /^rules$/,
agentsMd: /\.md$/,
commandsMd: /\.md$/,
skillsMd: /^SKILL\.md$/i,
keybindings: /^keybindings\.json$/,
claudeJson: /^\.claude\.json$/,
};
/**
* Discover all Claude Code config files under a target path.
* @param {string} targetPath
* @param {object} [opts]
* @param {number} [opts.maxFiles=500] - max files to return
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
* @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
*/
export async function discoverConfigFiles(targetPath, opts = {}) {
const maxFiles = opts.maxFiles || 2000;
const maxDepth = opts.maxDepth || 10;
const files = [];
const skippedRef = { count: 0 };
await walkForConfig(targetPath, targetPath, files, skippedRef, maxFiles, undefined, maxDepth);
if (opts.includeGlobal) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const claudeDir = join(home, '.claude');
try {
await stat(claudeDir);
await walkForConfig(claudeDir, claudeDir, files, skippedRef, maxFiles, 'user', maxDepth);
} catch { /* .claude dir doesn't exist */ }
// ~/.claude.json
const claudeJson = join(home, '.claude.json');
try {
const s = await stat(claudeJson);
files.push({
absPath: claudeJson,
relPath: '.claude.json',
type: 'claude-json',
scope: 'user',
size: s.size,
});
} catch { /* doesn't exist */ }
}
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(files, opts.excludeCache || false);
return { files: outFiles, skipped: skippedRef.count, staleCacheVersions };
}
/**
* Walk directory tree looking for config files.
*/
async function walkForConfig(dir, basePath, files, skippedRef, maxFiles, forceScope, maxDepth) {
if (files.length >= maxFiles) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (files.length >= maxFiles) break;
const fullPath = join(dir, entry.name);
const rel = relative(basePath, fullPath);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
skippedRef.count++;
continue;
}
// Check for .claude directory (contains settings, rules, etc.)
if (entry.name === '.claude' || entry.name === '.claude-plugin') {
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
continue;
}
// Check for rules/ inside .claude
if (entry.name === 'rules' && dirname(rel).includes('.claude')) {
await walkRulesDir(fullPath, basePath, files, maxFiles, forceScope || classifyScope(rel, basePath));
continue;
}
// Check for agents/, commands/, skills/, hooks/ dirs
if (['agents', 'commands', 'skills', 'hooks'].includes(entry.name)) {
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
continue;
}
// Recurse into subdirectories (configurable depth limit)
const depth = rel.split(sep).length;
if (depth < maxDepth) {
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
}
} else if (entry.isFile()) {
const fileType = classifyFile(entry.name, rel);
if (fileType) {
let s;
try {
s = await stat(fullPath);
} catch {
continue;
}
files.push({
absPath: fullPath,
relPath: rel,
type: fileType,
scope: forceScope || classifyScope(rel, basePath),
size: s.size,
});
}
}
}
}
/**
* Walk a rules directory and collect all files (including non-.md for validation).
*/
async function walkRulesDir(dir, basePath, files, maxFiles, scope) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (files.length >= maxFiles) break;
const fullPath = join(dir, entry.name);
if (entry.isFile()) {
let s;
try {
s = await stat(fullPath);
} catch {
continue;
}
files.push({
absPath: fullPath,
relPath: relative(basePath, fullPath),
type: 'rule',
scope,
size: s.size,
});
} else if (entry.isDirectory()) {
await walkRulesDir(fullPath, basePath, files, maxFiles, scope);
}
}
}
/**
* Classify a file by name and path.
* @returns {string | null}
*/
function classifyFile(name, relPath) {
if (CONFIG_PATTERNS.claudeMd.test(name)) return 'claude-md';
if (name === 'settings.json' || name === 'settings.local.json') {
if (relPath.includes('.claude')) return 'settings-json';
}
if (name === '.mcp.json') return 'mcp-json';
if (name === 'plugin.json' && relPath.includes('.claude-plugin')) return 'plugin-json';
if (name === 'hooks.json' && relPath.includes('hooks')) return 'hooks-json';
if (name === 'keybindings.json') return 'keybindings-json';
if (name === '.claude.json') return 'claude-json';
// Agent/command/skill markdown files
if (name.endsWith('.md') && relPath.includes(`agents${sep}`)) return 'agent-md';
if (name.endsWith('.md') && relPath.includes(`commands${sep}`)) return 'command-md';
if (/^SKILL\.md$/i.test(name)) return 'skill-md';
return null;
}
/**
* Determine the scope of a config file.
* @returns {'managed' | 'user' | 'project' | 'local' | 'plugin'}
*/
function classifyScope(relPath, basePath) {
if (relPath.includes('managed-settings')) return 'managed';
if (basePath.includes(`.claude${sep}plugins`)) return 'plugin';
if (relPath.includes('.local.')) return 'local';
const home = process.env.HOME || process.env.USERPROFILE || '';
if (basePath.startsWith(join(home, '.claude'))) return 'user';
return 'project';
}
/** Common developer directory names under $HOME */
const DEV_DIRS = ['repos', 'projects', 'src', 'code', 'dev', 'work', 'Sites', 'Developer'];
/**
* Discover all root paths for a full-machine scan.
* Only returns paths that actually exist on the filesystem.
* @returns {Promise<Array<{ path: string, maxDepth: number }>>}
*/
export async function discoverFullMachinePaths() {
const home = process.env.HOME || process.env.USERPROFILE || '';
const candidates = [
// ~/.claude — deepest (plugins can be 6+ levels deep)
{ path: join(home, '.claude'), maxDepth: 10 },
// Managed system paths
{ path: '/Library/Application Support/ClaudeCode', maxDepth: 5 },
{ path: '/etc/claude-code', maxDepth: 5 },
// Common developer directories
...DEV_DIRS.map(d => ({ path: join(home, d), maxDepth: 5 })),
];
const existing = [];
for (const c of candidates) {
try {
const s = await stat(c.path);
if (s.isDirectory()) existing.push(c);
} catch { /* not present */ }
}
return existing;
}
/**
* Discover config files across multiple root paths.
* Calls discoverConfigFiles() per root with correct basePath (preserves scope/relPath).
* Deduplicates files by absPath — first occurrence wins.
* @param {Array<{ path: string, maxDepth: number }>} roots
* @param {object} [opts]
* @param {number} [opts.maxFiles=2000] - global max across all roots
* @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 = {}) {
const maxFiles = opts.maxFiles || 2000;
const seen = new Set();
const allFiles = [];
let totalSkipped = 0;
for (const root of roots) {
if (allFiles.length >= maxFiles) break;
const result = await discoverConfigFiles(root.path, {
maxFiles: maxFiles - allFiles.length,
maxDepth: root.maxDepth,
});
totalSkipped += result.skipped;
for (const f of result.files) {
if (!seen.has(f.absPath)) {
seen.add(f.absPath);
allFiles.push(f);
}
}
}
// Handle ~/.claude.json separately (single file, not a directory)
const home = process.env.HOME || process.env.USERPROFILE || '';
const claudeJson = join(home, '.claude.json');
if (allFiles.length < maxFiles && !seen.has(claudeJson)) {
try {
const s = await stat(claudeJson);
allFiles.push({
absPath: claudeJson,
relPath: '.claude.json',
type: 'claude-json',
scope: 'user',
size: s.size,
});
} catch { /* doesn't exist */ }
}
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(allFiles, opts.excludeCache || false);
return { files: outFiles, skipped: totalSkipped, staleCacheVersions };
}
/**
* Read a file as UTF-8 text. Returns null on error or if binary.
* @param {string} absPath
* @returns {Promise<string | null>}
*/
export async function readTextFile(absPath) {
try {
const content = await readFile(absPath, 'utf-8');
// Check for binary (null bytes in first 8KB)
const sample = content.slice(0, 8192);
if (sample.includes('\0')) return null;
return content;
} catch {
return null;
}
}