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
452 lines
15 KiB
JavaScript
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;
|
|
}
|
|
}
|