feat: initial open marketplace with llm-security, config-audit, ultraplan-local

This commit is contained in:
Kjell Tore Guttormsen 2026-04-06 18:47:49 +02:00
commit f93d6abdae
380 changed files with 65935 additions and 0 deletions

View file

@ -0,0 +1,209 @@
/**
* CML Scanner CLAUDE.md Linter
* Validates structure, sections, length, @imports, frontmatter, and HTML comments.
* Finding IDs: CA-CML-NNN
*/
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
const SCANNER = 'CML';
const MAX_RECOMMENDED_LINES = 200;
const MAX_ABSOLUTE_LINES = 500;
/** Recommended sections for a project CLAUDE.md */
const RECOMMENDED_SECTIONS = [
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
{ pattern: /command|workflow|how to|getting started|usage/i, label: 'Commands/Workflows' },
{ pattern: /architect|structure|directory|layout/i, label: 'Architecture' },
{ pattern: /convention|pattern|rule|style/i, label: 'Conventions/Patterns' },
];
/**
* Scan all CLAUDE.md files discovered.
* @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 claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
if (claudeFiles.length === 0) {
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'No CLAUDE.md found',
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
recommendation: 'Run `/init` to create a starter CLAUDE.md, or create one manually.',
autoFixable: false,
}),
], 0, Date.now() - start);
}
const findings = [];
let filesScanned = 0;
for (const file of claudeFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const lines = lineCount(content);
const { frontmatter, body, bodyStartLine } = parseFrontmatter(content);
const sections = extractSections(body);
const imports = findImports(content);
// --- Length checks ---
if (lines > MAX_ABSOLUTE_LINES) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'CLAUDE.md exceeds 500 lines',
description: `${file.relPath} has ${lines} lines. Files over 500 lines significantly reduce Claude's adherence to instructions.`,
file: file.absPath,
evidence: `${lines} lines`,
recommendation: 'Split into @imports and .claude/rules/ files. Keep CLAUDE.md under 200 lines.',
autoFixable: false,
}));
} else if (lines > MAX_RECOMMENDED_LINES) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'CLAUDE.md exceeds recommended 200 lines',
description: `${file.relPath} has ${lines} lines. Best practice is under 200 lines for optimal adherence.`,
file: file.absPath,
evidence: `${lines} lines`,
recommendation: 'Consider using @imports or .claude/rules/ for detailed content.',
autoFixable: false,
}));
}
// --- Empty file ---
if (lines < 3) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'CLAUDE.md is nearly empty',
description: `${file.relPath} has only ${lines} lines.`,
file: file.absPath,
recommendation: 'Add project overview, commands/workflows, and conventions.',
autoFixable: false,
}));
continue; // Skip further checks for empty files
}
// --- Section checks (only for project/user scope) ---
if (file.scope === 'project' || file.scope === 'user') {
const sectionHeadings = sections.map(s => s.heading);
const missingSections = [];
for (const rec of RECOMMENDED_SECTIONS) {
const found = sectionHeadings.some(h => rec.pattern.test(h));
if (!found) {
missingSections.push(rec.label);
}
}
if (missingSections.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Missing recommended sections',
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
file: file.absPath,
evidence: `Present sections: ${sectionHeadings.slice(0, 5).join(', ') || '(none)'}`,
recommendation: `Add sections for: ${missingSections.join(', ')}`,
autoFixable: false,
}));
}
}
// --- No headings at all ---
if (sections.length === 0 && lines > 10) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'CLAUDE.md has no markdown headings',
description: `${file.relPath} has ${lines} lines but no ## headings. Structured content with headers improves Claude's ability to find and follow instructions.`,
file: file.absPath,
recommendation: 'Add markdown headings (##) to organize content into scannable sections.',
autoFixable: false,
}));
}
// --- @import checks ---
for (const imp of imports) {
// Check for @imports referencing non-existent files
// (Full resolution is in import-resolver scanner, here we just flag obvious issues)
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: '@import with deep relative path',
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
file: file.absPath,
line: imp.line,
evidence: `@${imp.path}`,
recommendation: 'Consider using absolute paths or moving the imported file closer.',
autoFixable: false,
}));
}
}
// --- HTML comment info ---
const htmlComments = (content.match(/<!--[\s\S]*?-->/g) || []).length;
if (htmlComments > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Uses HTML comments',
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
file: file.absPath,
evidence: `${htmlComments} HTML comment(s)`,
}));
}
// --- Duplicate content detection (simple: repeated lines) ---
const lineArr = content.split('\n');
const lineCounts = new Map();
for (const l of lineArr) {
const trimmed = l.trim();
if (trimmed.length > 20 && !trimmed.startsWith('#') && !trimmed.startsWith('|') && !trimmed.startsWith('-')) {
lineCounts.set(trimmed, (lineCounts.get(trimmed) || 0) + 1);
}
}
const duplicates = [...lineCounts.entries()].filter(([, count]) => count >= 3);
if (duplicates.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Repeated content detected',
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
file: file.absPath,
evidence: truncate(duplicates[0][0], 80),
recommendation: 'Extract repeated content into a shared @import or rules file.',
autoFixable: false,
}));
}
// --- TODO/FIXME markers ---
const todos = lineArr.filter(l => /\bTODO\b|\bFIXME\b|\bHACK\b/i.test(l));
if (todos.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Contains TODO/FIXME markers',
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
file: file.absPath,
evidence: truncate(todos[0].trim(), 80),
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

View file

@ -0,0 +1,238 @@
/**
* 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 { 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';
const SCANNER = 'CNF';
// Keys checked separately or not meaningful to compare
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
/**
* Extract the tool name prefix from a permission rule.
* e.g., "Bash(npm run *)" "Bash", "Read(src/**)" "Read"
* @param {string} rule
* @returns {{ tool: string, pattern: string }}
*/
function parsePermissionRule(rule) {
const match = rule.match(/^(\w+)\((.+)\)$/);
if (match) return { tool: match[1], pattern: match[2] };
return { tool: rule, pattern: '*' };
}
/**
* Flatten an object's top-level keys into a simple keyvalue 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
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
// Collect hooks files
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
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,
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) {
const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule);
for (const denyRule of bDeny) {
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) {
findings.push(finding({
scanner: SCANNER,
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) {
const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule);
for (const denyRule of aDeny) {
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) {
findings.push(finding({
scanner: SCANNER,
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,
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);
}

View file

@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Config-Audit Drift CLI
* Compare current configuration against a saved baseline.
* Usage:
* node drift-cli.mjs <path> --save [--name my-baseline]
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
* node drift-cli.mjs --list
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { runAllScanners } from './scan-orchestrator.mjs';
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let baselineName = 'default';
let save = false;
let list = false;
let jsonMode = false;
let includeGlobal = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--save') {
save = true;
} else if (args[i] === '--name' && args[i + 1]) {
baselineName = args[++i];
} else if (args[i] === '--baseline' && args[i + 1]) {
baselineName = args[++i];
} else if (args[i] === '--list') {
list = true;
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
// --- List mode ---
if (list) {
const result = await listBaselines();
if (jsonMode) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
if (result.baselines.length === 0) {
process.stderr.write('No baselines saved.\n');
process.stderr.write('Save one with: node drift-cli.mjs <path> --save\n');
} else {
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
process.stderr.write(' Saved Baselines\n');
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
for (const b of result.baselines) {
process.stderr.write(` ${b.name.padEnd(20)} ${b.findingCount} findings ${b.savedAt}\n`);
}
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
}
process.exit(0);
}
// --- Save mode ---
if (save) {
if (!jsonMode) {
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
process.stderr.write(`Saving baseline "${baselineName}" for ${resolve(targetPath)}\n\n`);
}
const envelope = await runAllScanners(targetPath, { includeGlobal });
const result = await saveBaseline(envelope, baselineName);
if (jsonMode) {
process.stdout.write(JSON.stringify({ saved: true, name: result.name, path: result.path }, null, 2) + '\n');
} else {
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
}
process.exit(0);
}
// --- Drift mode (default) ---
if (!jsonMode) {
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
process.stderr.write(`Baseline: ${baselineName}\n\n`);
}
// Load baseline
const baseline = await loadBaseline(baselineName);
if (!baseline) {
if (jsonMode) {
process.stdout.write(JSON.stringify({ error: `Baseline "${baselineName}" not found. Save one with --save.` }, null, 2) + '\n');
} else {
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
}
process.exit(1);
}
// Run current scan
const current = await runAllScanners(targetPath, { includeGlobal });
// Diff
const diff = diffEnvelopes(baseline, current);
if (jsonMode) {
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
} else {
const report = formatDiffReport(diff);
process.stderr.write('\n' + report + '\n');
}
// Exit code: 0=stable/improving, 1=degrading
if (diff.summary.trend === 'degrading') process.exit(1);
process.exit(0);
}
// Only run CLI if invoked directly
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -0,0 +1,410 @@
/**
* GAP Scanner Feature Gap Scanner
* Compares actual configuration against complete Claude Code feature register.
* 25 gap dimensions across 4 tiers. Always runs with includeGlobal: true.
* Finding IDs: CA-GAP-NNN
*/
import { resolve } from 'node:path';
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
const SCANNER = 'GAP';
/**
* @typedef {object} GapCheck
* @property {string} id - Short identifier (t1_1 through t4_5)
* @property {string} tier - t1|t2|t3|t4
* @property {string} title - Human-readable title
* @property {string} recommendation - What to do
* @property {(ctx: CheckContext) => Promise<boolean>} check - Returns true if feature IS present
*/
/**
* @typedef {object} CheckContext
* @property {import('./lib/file-discovery.mjs').ConfigFile[]} files
* @property {string} targetPath
* @property {Map<string, object>} parsedSettings - scope parsed JSON
* @property {Map<string, string>} fileContents - absPath content
*/
/**
* Check if a file belongs to the target project (vs global ~/.claude/).
* Needed because scope classification can be 'plugin' when running inside ~/.claude/plugins/.
* @param {CheckContext} ctx
* @param {import('./lib/file-discovery.mjs').ConfigFile} f
* @returns {boolean}
*/
function isTargetLocal(ctx, f) {
return f.absPath.startsWith(ctx.targetPath);
}
const TIER_SEVERITY = {
t1: SEVERITY.medium,
t2: SEVERITY.low,
t3: SEVERITY.info,
t4: SEVERITY.info,
};
/**
* Lazily read and cache file content.
* @param {CheckContext} ctx
* @param {string} absPath
* @returns {Promise<string|null>}
*/
async function getContent(ctx, absPath) {
if (ctx.fileContents.has(absPath)) return ctx.fileContents.get(absPath);
const content = await readTextFile(absPath);
ctx.fileContents.set(absPath, content);
return content;
}
/**
* Check if any settings file has a specific key.
* @param {CheckContext} ctx
* @param {string} key
* @returns {boolean}
*/
function anySettingsHas(ctx, key) {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed && key in parsed) return true;
}
return false;
}
/**
* Get a value from any settings file (first match).
* @param {CheckContext} ctx
* @param {string} key
* @returns {*}
*/
function getSettingsValue(ctx, key) {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed && key in parsed) return parsed[key];
}
return undefined;
}
/** @type {GapCheck[]} */
const GAP_CHECKS = [
// --- Tier 1: Foundation ---
{
id: 't1_1', tier: 't1',
title: 'No CLAUDE.md file',
recommendation: 'Create a CLAUDE.md at the project root with project-specific instructions, commands, and architecture.',
check: async (ctx) => ctx.files.some(f => f.type === 'claude-md' && isTargetLocal(ctx, f)),
},
{
id: 't1_2', tier: 't1',
title: 'No permissions configured',
recommendation: 'Add permissions.allow and permissions.deny in .claude/settings.json to control tool access.',
check: async (ctx) => {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.permissions && (parsed.permissions.allow?.length > 0 || parsed.permissions.deny?.length > 0)) {
return true;
}
}
return false;
},
},
{
id: 't1_3', tier: 't1',
title: 'No hooks configured',
recommendation: 'Add at least one hook (e.g., PreToolUse for security, Stop for session summaries). See knowledge/hook-events-reference.md.',
check: async (ctx) => {
if (ctx.files.some(f => f.type === 'hooks-json')) return true;
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
return Object.keys(parsed.hooks).length > 0;
}
}
return false;
},
},
{
id: 't1_4', tier: 't1',
title: 'No custom skills or commands',
recommendation: 'Create project-specific skills in .claude/skills/ or commands in .claude/commands/ to automate repetitive workflows.',
check: async (ctx) => ctx.files.some(f => f.type === 'skill-md' || f.type === 'command-md'),
},
{
id: 't1_5', tier: 't1',
title: 'No MCP servers configured',
recommendation: 'Add a .mcp.json at the project root to configure MCP servers for enhanced tool access.',
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json'),
},
// --- Tier 2: Configuration Depth ---
{
id: 't2_1', tier: 't2',
title: 'Settings only at one scope',
recommendation: 'Use all 3 settings scopes: ~/.claude/settings.json (user), .claude/settings.json (project), .claude/settings.local.json (local/personal).',
check: async (ctx) => {
const localSettings = ctx.files.filter(f => f.type === 'settings-json' && isTargetLocal(ctx, f));
const hasGlobal = ctx.files.some(f => f.type === 'settings-json' && !isTargetLocal(ctx, f));
return (localSettings.length >= 2) || (localSettings.length >= 1 && hasGlobal);
},
},
{
id: 't2_2', tier: 't2',
title: 'CLAUDE.md not modular',
recommendation: 'Use @imports or .claude/rules/ to split large CLAUDE.md files into focused modules.',
check: async (ctx) => {
// Has rules files OR has @imports in any CLAUDE.md
if (ctx.files.some(f => f.type === 'rule')) return true;
for (const file of ctx.files.filter(f => f.type === 'claude-md')) {
const content = await getContent(ctx, file.absPath);
if (content && findImports(content).length > 0) return true;
}
return false;
},
},
{
id: 't2_3', tier: 't2',
title: 'No path-scoped rules',
recommendation: 'Create .claude/rules/*.md with paths: frontmatter to apply rules only to matching files.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'rule')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && (frontmatter.paths || frontmatter.globs)) return true;
}
}
return false;
},
},
{
id: 't2_4', tier: 't2',
title: 'Auto-memory explicitly disabled',
recommendation: 'Enable auto-memory by removing autoMemoryEnabled: false from settings.',
check: async (ctx) => {
// Present (gap) only if explicitly disabled
const val = getSettingsValue(ctx, 'autoMemoryEnabled');
return val !== false;
},
},
{
id: 't2_5', tier: 't2',
title: 'Low hook diversity',
recommendation: 'Use hooks across 3+ events (e.g., SessionStart, PreToolUse, Stop) for comprehensive automation.',
check: async (ctx) => {
const events = new Set();
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
for (const event of Object.keys(parsed.hooks)) events.add(event);
}
}
for (const file of ctx.files.filter(f => f.type === 'hooks-json')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const parsed = parseJson(content);
const hookData = parsed?.hooks || parsed;
if (hookData && typeof hookData === 'object' && !Array.isArray(hookData)) {
for (const event of Object.keys(hookData)) events.add(event);
}
}
}
return events.size >= 3;
},
},
{
id: 't2_6', tier: 't2',
title: 'No custom subagents',
recommendation: 'Create custom agents in .claude/agents/ or ~/.claude/agents/ with specialized tools and model selection.',
check: async (ctx) => ctx.files.some(f => f.type === 'agent-md'),
},
{
id: 't2_7', tier: 't2',
title: 'No model configuration',
recommendation: 'Set model preferences in settings.json (model, modelOverrides) for cost/quality optimization.',
check: async (ctx) => anySettingsHas(ctx, 'model') || anySettingsHas(ctx, 'modelOverrides'),
},
// --- Tier 3: Advanced Features ---
{
id: 't3_1', tier: 't3',
title: 'No status line configured',
recommendation: 'Configure statusLine in settings.json to show context window usage, cost, and model info.',
check: async (ctx) => anySettingsHas(ctx, 'statusLine'),
},
{
id: 't3_2', tier: 't3',
title: 'No custom keybindings',
recommendation: 'Create ~/.claude/keybindings.json to customize keyboard shortcuts (e.g., bind chat:newline to Shift+Enter).',
check: async (ctx) => ctx.files.some(f => f.type === 'keybindings-json'),
},
{
id: 't3_3', tier: 't3',
title: 'Using default output style',
recommendation: 'Try "Explanatory" or "Learning" output styles, or create custom styles in .claude/output-styles/.',
check: async (ctx) => anySettingsHas(ctx, 'outputStyle'),
},
{
id: 't3_4', tier: 't3',
title: 'No worktree workflow',
recommendation: 'Use --worktree for parallel feature development. Configure worktree.symlinkDirectories for node_modules.',
check: async (ctx) => anySettingsHas(ctx, 'worktree'),
},
{
id: 't3_5', tier: 't3',
title: 'No advanced skill frontmatter',
recommendation: 'Use disable-model-invocation, context:fork, or argument-hint in skill frontmatter for better control.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'skill-md')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && (
frontmatter.disable_model_invocation ||
frontmatter.context === 'fork' ||
frontmatter.argument_hint
)) return true;
}
}
return false;
},
},
{
id: 't3_6', tier: 't3',
title: 'No subagent isolation',
recommendation: 'Use isolation: worktree in agent frontmatter for safe parallel development.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && frontmatter.isolation === 'worktree') return true;
}
}
return false;
},
},
{
id: 't3_7', tier: 't3',
title: 'No dynamic skill context',
recommendation: 'Use !`command` syntax in skills to inject dynamic context (e.g., !`git branch --show-current`).',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'skill-md' || f.type === 'command-md')) {
const content = await getContent(ctx, file.absPath);
if (content && /!`[^`]+`/.test(content)) return true;
}
return false;
},
},
{
id: 't3_8', tier: 't3',
title: 'No autoMode classifier',
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
},
// --- Tier 4: Team/Enterprise ---
{
id: 't4_1', tier: 't4',
title: 'No project .mcp.json in git',
recommendation: 'Add .mcp.json to git so the team shares MCP server configuration.',
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json' && isTargetLocal(ctx, f)),
},
{
id: 't4_2', tier: 't4',
title: 'No custom plugin',
recommendation: 'Package reusable skills, agents, and hooks as a Claude Code plugin with .claude-plugin/plugin.json.',
check: async (ctx) => ctx.files.some(f => f.type === 'plugin-json'),
},
{
id: 't4_3', tier: 't4',
title: 'Agent teams not enabled',
recommendation: 'Enable agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 for parallel multi-agent workflows.',
check: async (ctx) => {
for (const parsed of ctx.parsedSettings.values()) {
const env = parsed?.env;
if (env && env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1') return true;
}
return !!process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
},
},
{
id: 't4_4', tier: 't4',
title: 'No managed settings',
recommendation: 'Use managed-settings.json for organization-wide policy enforcement.',
check: async (ctx) => ctx.files.some(f => f.scope === 'managed'),
},
{
id: 't4_5', tier: 't4',
title: 'No LSP plugins',
recommendation: 'Add .lsp.json for real-time code intelligence from language servers.',
check: async (ctx) => ctx.files.some(f => f.relPath.endsWith('.lsp.json')),
},
];
/**
* Scan for feature gaps against Claude Code feature register.
* @param {string} targetPath
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} sharedDiscovery - Used when provided with files; otherwise runs own discovery with includeGlobal
* @returns {Promise<object>}
*/
export async function scan(targetPath, sharedDiscovery) {
const start = Date.now();
const findings = [];
// Use shared discovery if it has files (e.g. from full-machine mode), otherwise run own
const discovery = (sharedDiscovery && sharedDiscovery.files && sharedDiscovery.files.length > 0)
? sharedDiscovery
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
// Parse all settings files upfront
const parsedSettings = new Map();
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
const content = await readTextFile(file.absPath);
if (content) {
const parsed = parseJson(content);
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
}
}
const ctx = {
files: discovery.files,
targetPath: resolve(targetPath),
parsedSettings,
fileContents: new Map(),
};
for (const gap of GAP_CHECKS) {
const present = await gap.check(ctx);
if (!present) {
findings.push(finding({
scanner: SCANNER,
severity: TIER_SEVERITY[gap.tier],
title: gap.title,
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
recommendation: gap.recommendation,
category: gap.tier,
}));
}
}
const filesScanned = discovery.files.length;
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}
/**
* Group GAP findings into impact categories for opportunity-based display.
* @param {object[]} findings - GAP scanner findings (each has .category = t1|t2|t3|t4)
* @returns {{ highImpact: object[], mediumImpact: object[], explore: object[] }}
*/
export function opportunitySummary(findings) {
const highImpact = [];
const mediumImpact = [];
const explore = [];
for (const f of findings) {
if (f.category === 't1') highImpact.push(f);
else if (f.category === 't2') mediumImpact.push(f);
else explore.push(f);
}
return { highImpact, mediumImpact, explore };
}

View file

@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* Config-Audit Fix CLI
* Standalone entry point for running fixes without the command.
* Usage: node fix-cli.mjs <path> [--apply] [--global] [--json]
* Dry-run by default must pass --apply to write changes.
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { runAllScanners } from './scan-orchestrator.mjs';
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
import { createBackup } from './lib/backup.mjs';
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let apply = false;
let jsonMode = false;
let includeGlobal = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--apply') {
apply = true;
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
const resolvedPath = resolve(targetPath);
if (!jsonMode) {
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
process.stderr.write(`Target: ${resolvedPath}\n`);
process.stderr.write(`Mode: ${apply ? 'APPLY' : 'DRY-RUN'}\n\n`);
process.stderr.write(`Scanning...\n`);
}
// 1. Run all scanners
const envelope = await runAllScanners(targetPath, { includeGlobal });
// 2. Plan fixes
const { fixes, skipped, manual } = planFixes(envelope);
if (!jsonMode) {
process.stderr.write(`\n`);
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
process.stderr.write(` Config-Audit Fix Plan\n`);
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`);
if (fixes.length > 0) {
process.stderr.write(` Auto-fixable (${fixes.length}):\n`);
for (let i = 0; i < fixes.length; i++) {
process.stderr.write(` ${i + 1}. [${fixes[i].findingId}] ${fixes[i].description}\n`);
}
} else {
process.stderr.write(` No auto-fixable issues found.\n`);
}
if (manual.length > 0) {
process.stderr.write(`\n Manual (${manual.length}):\n`);
for (let i = 0; i < manual.length; i++) {
process.stderr.write(` ${fixes.length + i + 1}. [${manual[i].findingId}] ${manual[i].title}\n`);
}
}
if (skipped.length > 0) {
process.stderr.write(`\n Skipped (${skipped.length}): could not generate fix plan\n`);
}
process.stderr.write(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
}
// 3. Apply or dry-run
let applied = [];
let failed = [];
let verified = [];
let regressions = [];
let backupId = null;
if (fixes.length === 0) {
if (jsonMode) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
process.exit(0);
}
if (apply) {
// Create backup first
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
const backup = createBackup(filesToBackup);
backupId = backup.backupId;
if (!jsonMode) {
process.stderr.write(`\n Backup created: ${backup.backupPath}\n`);
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
}
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
applied = result.applied;
failed = result.failed;
if (!jsonMode) {
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
if (failed.length > 0) {
for (const f of failed) {
process.stderr.write(` FAILED: [${f.findingId}] ${f.error}\n`);
}
}
}
// 4. Verify
if (applied.length > 0) {
if (!jsonMode) {
process.stderr.write(`\n Verifying...\n`);
}
const verification = await verifyFixes(envelope, applied);
verified = verification.verified;
regressions = verification.regressions;
if (!jsonMode) {
process.stderr.write(` Verified: ${verified.length}/${applied.length}\n`);
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
}
}
} else {
// Dry-run mode
const result = await applyFixes(fixes, { dryRun: true });
applied = result.applied;
if (!jsonMode) {
process.stderr.write(`\n Dry-run complete. Pass --apply to execute.\n`);
}
}
// JSON output
if (jsonMode) {
const output = {
planned: fixes.map(f => ({
findingId: f.findingId,
file: f.file,
type: f.type,
description: f.description,
})),
applied: applied.map(a => ({
findingId: a.findingId,
file: a.file,
status: a.status,
})),
failed: failed.map(f => ({
findingId: f.findingId,
file: f.file,
status: f.status,
error: f.error,
})),
verified,
regressions,
manual: manual.map(m => ({
findingId: m.findingId,
title: m.title,
recommendation: m.recommendation,
})),
backupId,
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
}
// Only run CLI if invoked directly
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -0,0 +1,666 @@
/**
* Config-Audit Fix Engine
* Deterministic fix engine: maps scanner findings to concrete file changes.
* Zero external dependencies.
*/
import { readFile, writeFile, rename, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { createBackup } from './lib/backup.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
/**
* Fix type constants.
*/
const FIX_TYPES = {
JSON_KEY_ADD: 'json-key-add',
JSON_KEY_REMOVE: 'json-key-remove',
JSON_KEY_TYPE_FIX: 'json-key-type-fix',
JSON_RESTRUCTURE: 'json-restructure',
FRONTMATTER_RENAME: 'frontmatter-rename',
FILE_RENAME: 'file-rename',
};
/** Valid effortLevel values for nearest-match */
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
/**
* Plan fixes from a scanner envelope.
* @param {object} envelope - Full scanner envelope from scan-orchestrator
* @returns {{ fixes: object[], skipped: object[], manual: object[] }}
*/
export function planFixes(envelope) {
const fixes = [];
const skipped = [];
const manual = [];
for (const scanner of envelope.scanners) {
for (const finding of scanner.findings) {
if (!finding.autoFixable) {
manual.push({
findingId: finding.id,
title: finding.title,
file: finding.file,
recommendation: finding.recommendation,
});
continue;
}
const fixPlan = createFixPlan(finding);
if (fixPlan) {
fixes.push(fixPlan);
} else {
skipped.push(finding);
}
}
}
// Sort fixes by severity weight (critical first)
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
return { fixes, skipped, manual };
}
/**
* Create a fix plan for a single finding.
* @param {object} finding
* @returns {object|null}
*/
function createFixPlan(finding) {
if (!finding.file) return null;
const base = {
findingId: finding.id,
file: finding.file,
severity: finding.severity,
description: '',
before: null,
after: null,
type: null,
};
const scanner = finding.scanner;
const title = finding.title;
// --- SET scanner fixes ---
if (scanner === 'SET') {
if (title === 'Missing $schema reference') {
return {
...base,
type: FIX_TYPES.JSON_KEY_ADD,
description: 'Add $schema reference for IDE autocomplete',
before: '(no $schema key)',
after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"',
key: '$schema',
value: 'https://json.schemastore.org/claude-code-settings.json',
};
}
if (title === 'Deprecated settings key') {
const key = extractKeyFromEvidence(finding.evidence);
if (!key) return null;
return {
...base,
type: FIX_TYPES.JSON_KEY_REMOVE,
description: `Remove deprecated key "${key}"`,
before: finding.evidence,
after: '(key removed)',
key,
};
}
if (title === 'Type mismatch in settings') {
const key = extractKeyFromEvidence(finding.evidence);
if (!key) return null;
const expectedType = extractExpectedType(finding.description);
return {
...base,
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
description: `Fix type of "${key}" to ${expectedType}`,
before: finding.evidence,
after: `(converted to ${expectedType})`,
key,
expectedType,
};
}
if (title === 'Invalid effortLevel value') {
return {
...base,
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
description: 'Fix effortLevel to nearest valid value',
before: finding.evidence,
after: '(nearest valid effortLevel)',
key: 'effortLevel',
expectedType: 'effortLevel',
};
}
if (title === 'Hooks configured as array instead of object') {
return {
...base,
type: FIX_TYPES.JSON_RESTRUCTURE,
description: 'Convert hooks from array to object format',
before: '"hooks": [...]',
after: '"hooks": { ... }',
restructureType: 'hooks-array-to-object',
};
}
}
// --- HKV scanner fixes ---
if (scanner === 'HKV') {
if (title === 'Matcher must be a string, not an object') {
return {
...base,
type: FIX_TYPES.JSON_RESTRUCTURE,
description: 'Convert matcher from object to string',
before: finding.evidence,
after: '"matcher": "ToolName"',
restructureType: 'matcher-object-to-string',
event: extractEventFromDescription(finding.description),
};
}
if (title === 'Hook timeout must be a number') {
return {
...base,
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
description: 'Convert timeout to number',
before: finding.evidence,
after: '(parsed to number)',
key: 'timeout',
expectedType: 'number',
event: extractEventFromDescription(finding.description),
};
}
}
// --- RUL scanner fixes ---
if (scanner === 'RUL') {
if (title === 'Rule uses deprecated "globs" field') {
return {
...base,
type: FIX_TYPES.FRONTMATTER_RENAME,
description: 'Rename "globs" to "paths" in frontmatter',
before: 'globs:',
after: 'paths:',
oldField: 'globs',
newField: 'paths',
};
}
if (title === 'Rule file is not .md') {
const newPath = finding.file.replace(/\.[^.]+$/, '.md');
return {
...base,
type: FIX_TYPES.FILE_RENAME,
description: `Rename to .md extension`,
before: finding.file,
after: newPath,
newPath,
};
}
}
return null;
}
/**
* Apply planned fixes to files.
* @param {object[]} fixPlans - Array of fix plans from planFixes()
* @param {object} opts
* @param {boolean} [opts.dryRun=false]
* @param {string} [opts.backupDir] - Required if not dryRun
* @returns {Promise<{ applied: object[], failed: object[] }>}
*/
export async function applyFixes(fixPlans, opts = {}) {
const applied = [];
const failed = [];
if (!opts.dryRun && !opts.backupDir) {
throw new Error('backupDir is required when not in dryRun mode');
}
for (const plan of fixPlans) {
if (opts.dryRun) {
applied.push({
findingId: plan.findingId,
file: plan.file,
status: 'dry-run',
type: plan.type,
description: plan.description,
});
continue;
}
try {
await applyFix(plan);
applied.push({
findingId: plan.findingId,
file: plan.file,
status: 'applied',
type: plan.type,
description: plan.description,
});
} catch (err) {
failed.push({
findingId: plan.findingId,
file: plan.file,
status: 'failed',
error: err.message,
type: plan.type,
});
}
}
return { applied, failed };
}
/**
* Apply a single fix.
* @param {object} plan
*/
async function applyFix(plan) {
switch (plan.type) {
case FIX_TYPES.JSON_KEY_ADD:
await applyJsonKeyAdd(plan);
break;
case FIX_TYPES.JSON_KEY_REMOVE:
await applyJsonKeyRemove(plan);
break;
case FIX_TYPES.JSON_KEY_TYPE_FIX:
await applyJsonKeyTypeFix(plan);
break;
case FIX_TYPES.JSON_RESTRUCTURE:
await applyJsonRestructure(plan);
break;
case FIX_TYPES.FRONTMATTER_RENAME:
await applyFrontmatterRename(plan);
break;
case FIX_TYPES.FILE_RENAME:
await applyFileRename(plan);
break;
default:
throw new Error(`Unknown fix type: ${plan.type}`);
}
}
/**
* Add a key to a JSON file (as first key for $schema).
*/
async function applyJsonKeyAdd(plan) {
const content = await readFile(plan.file, 'utf-8');
const parsed = parseJson(content);
if (parsed === null) throw new Error('Invalid JSON');
// For $schema, insert as first key
if (plan.key === '$schema') {
const newObj = { $schema: plan.value, ...parsed };
await writeJsonFile(plan.file, newObj);
} else {
parsed[plan.key] = plan.value;
await writeJsonFile(plan.file, parsed);
}
}
/**
* Remove a key from a JSON file.
*/
async function applyJsonKeyRemove(plan) {
const content = await readFile(plan.file, 'utf-8');
const parsed = parseJson(content);
if (parsed === null) throw new Error('Invalid JSON');
delete parsed[plan.key];
await writeJsonFile(plan.file, parsed);
}
/**
* Fix the type of a JSON key value.
*/
async function applyJsonKeyTypeFix(plan) {
const content = await readFile(plan.file, 'utf-8');
const parsed = parseJson(content);
if (parsed === null) throw new Error('Invalid JSON');
// Handle nested hook timeout fixes
if (plan.key === 'timeout' && plan.event) {
fixTimeoutInHooks(parsed, plan.event);
await writeJsonFile(plan.file, parsed);
return;
}
// Handle effortLevel special case
if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') {
parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel);
await writeJsonFile(plan.file, parsed);
return;
}
// Generic type conversion
if (parsed[plan.key] !== undefined) {
parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType);
await writeJsonFile(plan.file, parsed);
}
}
/**
* Restructure JSON (hooks arrayobject, matcher objectstring).
*/
async function applyJsonRestructure(plan) {
const content = await readFile(plan.file, 'utf-8');
const parsed = parseJson(content);
if (parsed === null) throw new Error('Invalid JSON');
if (plan.restructureType === 'hooks-array-to-object') {
restructureHooksArrayToObject(parsed);
await writeJsonFile(plan.file, parsed);
return;
}
if (plan.restructureType === 'matcher-object-to-string') {
restructureMatcherObjectToString(parsed, plan.event);
await writeJsonFile(plan.file, parsed);
return;
}
throw new Error(`Unknown restructure type: ${plan.restructureType}`);
}
/**
* Rename a frontmatter field in a markdown file.
*/
async function applyFrontmatterRename(plan) {
const content = await readFile(plan.file, 'utf-8');
// Replace the field name in the frontmatter section only
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
if (!fmMatch) throw new Error('No frontmatter found');
const before = fmMatch[2];
const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm');
const after = before.replace(regex, `${plan.newField}$2`);
if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`);
const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length);
await writeFile(plan.file, newContent, 'utf-8');
// Validate frontmatter still parses
const { frontmatter } = parseFrontmatter(newContent);
if (!frontmatter) throw new Error('Frontmatter parse failed after rename');
}
/**
* Rename a file (change extension to .md).
*/
async function applyFileRename(plan) {
try {
await stat(plan.file);
} catch {
throw new Error(`Source file not found: ${plan.file}`);
}
// Check target doesn't already exist
try {
await stat(plan.newPath);
throw new Error(`Target already exists: ${plan.newPath}`);
} catch (err) {
if (err.message.startsWith('Target already exists')) throw err;
// File doesn't exist — good
}
await rename(plan.file, plan.newPath);
}
// --- Helper functions ---
/**
* Write a JSON object to a file with 2-space indent.
*/
async function writeJsonFile(filePath, obj) {
const json = JSON.stringify(obj, null, 2) + '\n';
// Validate the JSON we're about to write
const reparsed = parseJson(json);
if (reparsed === null) throw new Error('Generated invalid JSON');
await writeFile(filePath, json, 'utf-8');
}
/**
* Convert a value to the expected type.
*/
function convertType(value, expectedType) {
switch (expectedType) {
case 'boolean':
if (typeof value === 'string') {
if (value.toLowerCase() === 'true' || value === '1') return true;
if (value.toLowerCase() === 'false' || value === '0') return false;
}
if (typeof value === 'number') return value !== 0;
return Boolean(value);
case 'number':
if (typeof value === 'string') {
const num = Number(value);
return isNaN(num) ? 10000 : num; // default 10000 for timeouts
}
return Number(value);
case 'string':
return String(value);
default:
return value;
}
}
/**
* Find the nearest valid effortLevel.
*/
function findNearestEffortLevel(value) {
if (typeof value !== 'string') return 'medium';
const lower = value.toLowerCase();
// Simple distance-based matching
let best = 'medium';
let bestDist = Infinity;
for (const level of VALID_EFFORT_LEVELS) {
const dist = levenshtein(lower, level);
if (dist < bestDist) {
bestDist = dist;
best = level;
}
}
return best;
}
/**
* Simple Levenshtein distance.
*/
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
);
}
}
return dp[m][n];
}
/**
* Convert hooks array to object format.
* Best-effort: wraps array items under "PreToolUse" if they have event fields,
* otherwise groups by event property.
*/
function restructureHooksArrayToObject(parsed) {
if (!Array.isArray(parsed.hooks)) return;
const hooksObj = {};
for (const item of parsed.hooks) {
const event = item.event || 'PreToolUse';
if (!hooksObj[event]) hooksObj[event] = [];
// Build the handler group
const group = {};
if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher);
group.hooks = [];
if (item.command) {
group.hooks.push({
type: item.type || 'command',
command: item.command,
...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}),
});
} else if (item.hooks && Array.isArray(item.hooks)) {
group.hooks = item.hooks;
}
hooksObj[event].push(group);
}
parsed.hooks = hooksObj;
}
/**
* Convert matcher from object to string in hooks config.
*/
function restructureMatcherObjectToString(parsed, event) {
const hooks = parsed.hooks || parsed;
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
for (const [eventKey, handlers] of Object.entries(hooks)) {
if (event && eventKey !== event) continue;
if (!Array.isArray(handlers)) continue;
for (const group of handlers) {
if (group.matcher && typeof group.matcher === 'object') {
// Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" }
const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0];
group.matcher = typeof tool === 'string' ? tool : 'Bash';
}
}
}
}
/**
* Fix timeout type in nested hooks config.
*/
function fixTimeoutInHooks(parsed, event) {
const hooks = parsed.hooks || parsed;
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
for (const [eventKey, handlers] of Object.entries(hooks)) {
if (event && eventKey !== event) continue;
if (!Array.isArray(handlers)) continue;
for (const group of handlers) {
if (!group.hooks || !Array.isArray(group.hooks)) continue;
for (const hook of group.hooks) {
if (hook.timeout !== undefined && typeof hook.timeout !== 'number') {
const num = Number(hook.timeout);
hook.timeout = isNaN(num) ? 10000 : num;
}
}
}
}
}
/**
* Extract key name from evidence string like: 'someKey: "value"'
*/
function extractKeyFromEvidence(evidence) {
if (!evidence) return null;
const match = evidence.match(/^(\w+)\s*:/);
return match ? match[1] : null;
}
/**
* Extract expected type from description string like: 'should be boolean, got string'
*/
function extractExpectedType(description) {
const match = description.match(/should be (\w+)/);
return match ? match[1] : 'string';
}
/**
* Extract event name from description like: '"PreToolUse" has a matcher...'
*/
function extractEventFromDescription(description) {
const match = description.match(/"(\w+)"/);
return match ? match[1] : null;
}
/**
* Verify fixes by re-running affected scanners.
* @param {object} originalEnvelope - Original scanner envelope
* @param {object[]} appliedResults - Results from applyFixes()
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
*/
export async function verifyFixes(originalEnvelope, appliedResults) {
const targetPath = originalEnvelope.meta.target;
const verified = [];
const regressions = [];
const newFindings = [];
// Re-scan the target
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
// Build set of original finding IDs that were fixed
const fixedIds = new Set(
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
);
// Build set of new finding titles for comparison
const newFindingMap = new Map();
for (const scanner of newEnvelope.scanners) {
for (const f of scanner.findings) {
newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f);
}
}
// Check that fixed findings are gone
for (const scanner of originalEnvelope.scanners) {
for (const f of scanner.findings) {
if (!fixedIds.has(f.id)) continue;
const key = `${f.scanner}:${f.title}:${f.file}`;
// For file-rename fixes, the original file path won't exist anymore
const fixResult = appliedResults.find(r => r.findingId === f.id);
if (fixResult && fixResult.type === 'file-rename') {
// Check that the finding doesn't reappear at the new path
verified.push(f.id);
continue;
}
if (newFindingMap.has(key)) {
regressions.push(f.id);
} else {
verified.push(f.id);
}
}
}
// Check for any completely new findings not in original
const originalKeys = new Set();
for (const scanner of originalEnvelope.scanners) {
for (const f of scanner.findings) {
originalKeys.add(`${f.scanner}:${f.title}:${f.file}`);
}
}
for (const [key, f] of newFindingMap) {
if (!originalKeys.has(key)) {
newFindings.push(f);
}
}
return { verified, regressions, newFindings };
}
export { FIX_TYPES };

View file

@ -0,0 +1,270 @@
/**
* HKV Scanner Hook Validator
* Validates hooks.json format, script existence, event validity, timeouts.
* Finding IDs: CA-HKV-NNN
*/
import { readTextFile, discoverConfigFiles } 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 { stat } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
const SCANNER = 'HKV';
/** All valid hook events (as of April 2026) */
const VALID_EVENTS = new Set([
'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
'PostToolUse', 'PostToolUseFailure',
'SubagentStart', 'SubagentStop',
'TaskCreated', 'TaskCompleted',
'Stop', 'StopFailure',
'TeammateIdle', 'Notification',
'ConfigChange', 'CwdChanged', 'FileChanged',
'WorktreeCreate', 'WorktreeRemove',
'PreCompact', 'PostCompact',
'Elicitation', 'ElicitationResult',
'SessionEnd',
]);
/** Valid hook handler types */
const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
/** Reasonable timeout range */
const MIN_TIMEOUT = 1000;
const MAX_TIMEOUT = 300000; // 5 minutes
/**
* Scan all hooks.json files and hook configs in settings.json.
* @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 hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
const findings = [];
let filesScanned = 0;
// Scan standalone hooks.json files
for (const file of hooksFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const parsed = parseJson(content);
if (parsed === null) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid JSON in hooks.json',
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
file: file.absPath,
recommendation: 'Fix JSON syntax errors.',
autoFixable: false,
}));
continue;
}
const hooksConfig = parsed.hooks || parsed;
await validateHooksObject(hooksConfig, file, findings, dirname(file.absPath));
}
// Scan hooks in settings.json files
for (const file of settingsFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
const parsed = parseJson(content);
if (!parsed || !parsed.hooks) continue;
filesScanned++;
if (Array.isArray(parsed.hooks)) {
// Already reported by settings-validator, skip here
continue;
}
await validateHooksObject(parsed.hooks, file, findings, dirname(file.absPath));
}
if (hooksFiles.length === 0 && !settingsFiles.some(async f => {
const c = await readTextFile(f.absPath);
const p = c ? parseJson(c) : null;
return p && p.hooks;
})) {
// No hooks at all — this is noted but not an error
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}
/**
* Validate a hooks object (event key handler array).
*/
async function validateHooksObject(hooks, file, findings, baseDir) {
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Hooks must be an object with event keys',
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
file: file.absPath,
recommendation: 'Use format: { "PreToolUse": [...], "Stop": [...] }',
autoFixable: false,
}));
return;
}
for (const [event, handlers] of Object.entries(hooks)) {
// Validate event name
if (!VALID_EVENTS.has(event)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Unknown hook event',
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
file: file.absPath,
evidence: event,
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (26 total)`,
autoFixable: false,
}));
continue;
}
if (!Array.isArray(handlers)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Hook handlers must be an array',
description: `${file.relPath}: handlers for "${event}" is not an array.`,
file: file.absPath,
evidence: `"${event}": ${typeof handlers}`,
recommendation: `Use format: "${event}": [{ "hooks": [...] }]`,
autoFixable: false,
}));
continue;
}
for (const handlerGroup of handlers) {
// Validate matcher format
if (handlerGroup.matcher !== undefined) {
if (typeof handlerGroup.matcher === 'object') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Matcher must be a string, not an object',
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
file: file.absPath,
evidence: JSON.stringify(handlerGroup.matcher),
recommendation: 'Change matcher to a string: "matcher": "Bash"',
autoFixable: true,
}));
}
}
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Missing hooks array in handler group',
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
file: file.absPath,
recommendation: 'Add "hooks": [{ "type": "command", "command": "..." }]',
autoFixable: false,
}));
continue;
}
for (const hook of handlerGroup.hooks) {
// Validate handler type
if (!hook.type || !VALID_TYPES.has(hook.type)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hook handler type',
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
file: file.absPath,
evidence: `type: "${hook.type || ''}"`,
recommendation: `Valid types: ${[...VALID_TYPES].join(', ')}`,
autoFixable: false,
}));
}
// For command hooks, check script existence
if (hook.type === 'command' && hook.command) {
const scriptPath = extractScriptPath(hook.command, baseDir);
if (scriptPath) {
try {
await stat(scriptPath);
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Hook script not found',
description: `${file.relPath}: "${event}" references script that does not exist.`,
file: file.absPath,
evidence: hook.command,
recommendation: `Create the script at: ${scriptPath}`,
autoFixable: false,
}));
}
}
}
// Timeout validation
if (hook.timeout !== undefined) {
if (typeof hook.timeout !== 'number') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Hook timeout must be a number',
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
file: file.absPath,
evidence: `timeout: ${JSON.stringify(hook.timeout)}`,
recommendation: 'Set timeout to a number (milliseconds).',
autoFixable: true,
}));
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Hook timeout outside recommended range',
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
file: file.absPath,
evidence: `timeout: ${hook.timeout}`,
recommendation: `Set timeout between ${MIN_TIMEOUT} and ${MAX_TIMEOUT}ms.`,
autoFixable: false,
}));
}
}
}
}
}
}
/**
* Extract a filesystem path from a hook command string.
* Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
*/
function extractScriptPath(command, baseDir) {
// Extract the script path from common patterns:
// "bash /path/to/script.sh"
// "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
if (!match) return null;
let scriptPath = match[1].trim();
// Replace ${CLAUDE_PLUGIN_ROOT} with baseDir (best guess)
scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));
// Don't validate absolute paths that use env vars we can't resolve
if (scriptPath.includes('$')) return null;
return resolve(baseDir, scriptPath);
}

View file

@ -0,0 +1,185 @@
/**
* IMP Scanner Import Resolver
* Resolves @import references in CLAUDE.md files: broken links, circular refs, deep chains.
* Finding IDs: CA-IMP-NNN
*/
import { resolve, dirname, basename } from 'node:path';
import { tmpdir } from 'node:os';
import { stat } from 'node:fs/promises';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports } from './lib/yaml-parser.mjs';
import { truncate } from './lib/string-utils.mjs';
const SCANNER = 'IMP';
const MAX_CHAIN_DEPTH = 5;
const HARD_LIMIT = 20;
/**
* Check if a file exists.
* @param {string} absPath
* @returns {Promise<boolean>}
*/
async function fileExists(absPath) {
try {
await stat(absPath);
return true;
} catch {
return false;
}
}
/**
* Resolve an import path relative to the containing file.
* @param {string} importPath
* @param {string} containingFile
* @returns {{ resolved: string, hasTilde: boolean }}
*/
function resolveImportPath(importPath, containingFile) {
const hasTilde = importPath.startsWith('~');
let resolved;
if (hasTilde) {
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
resolved = resolve(importPath.replace(/^~/, home));
} else if (importPath.startsWith('/')) {
resolved = importPath;
} else {
resolved = resolve(dirname(containingFile), importPath);
}
return { resolved, hasTilde };
}
/**
* Walk imports recursively from a starting file via DFS.
* @param {string} file - Absolute path to current file
* @param {string[]} chain - Current chain of files (for cycle detection)
* @param {Set<string>} reported - Set of "from::to" pairs already reported
* @param {object[]} findings - Accumulator for findings
*/
async function walkImports(file, chain, reported, findings) {
const content = await readTextFile(file);
if (!content) return;
const imports = findImports(content);
for (const imp of imports) {
const { resolved, hasTilde } = resolveImportPath(imp.path, file);
const reportKey = `${file}::${resolved}`;
// Tilde path warning
if (hasTilde && !reported.has(`tilde::${resolved}`)) {
reported.add(`tilde::${resolved}`);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Tilde path in @import',
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
file,
line: imp.line,
evidence: `@${imp.path}`,
recommendation: 'Use a relative path or absolute path without tilde expansion.',
}));
}
// Check file existence
const exists = await fileExists(resolved);
if (!exists) {
if (!reported.has(reportKey)) {
reported.add(reportKey);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Broken @import link',
description: `@${imp.path} references a file that does not exist.`,
file,
line: imp.line,
evidence: `@${imp.path}${truncate(resolved, 80)}`,
recommendation: 'Fix the path or create the missing file.',
}));
}
continue;
}
// Circular reference detection
if (chain.includes(resolved)) {
if (!reported.has(reportKey)) {
reported.add(reportKey);
const cycleStart = chain.indexOf(resolved);
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Circular @import reference',
description: `@${imp.path} creates a circular import chain.`,
file,
line: imp.line,
evidence: `${cycle}${basename(resolved)}`,
recommendation: 'Break the circular dependency by removing one of the @imports.',
}));
}
continue;
}
// Deep chain warning
if (chain.length >= MAX_CHAIN_DEPTH) {
if (!reported.has(`deep::${resolved}`)) {
reported.add(`deep::${resolved}`);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Deep @import chain',
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
file,
line: imp.line,
evidence: `Chain depth: ${chain.length}`,
recommendation: 'Flatten the import hierarchy to reduce nesting.',
}));
}
continue;
}
// Hard limit safety bail
if (chain.length >= HARD_LIMIT) continue;
// Recurse
await walkImports(resolved, [...chain, resolved], reported, findings);
}
}
/**
* Scan all CLAUDE.md files for @import issues.
* @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 claudeMdFiles = discovery.files.filter(f => f.type === 'claude-md');
const findings = [];
let filesScanned = 0;
if (claudeMdFiles.length === 0) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
const reported = new Set();
for (const file of claudeMdFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
const imports = findImports(content);
if (imports.length === 0) {
filesScanned++;
continue;
}
filesScanned++;
await walkImports(file.absPath, [file.absPath], reported, findings);
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

View file

@ -0,0 +1,179 @@
/**
* Backup library for config-audit.
* Creates timestamped backups of config files with checksums and manifests.
* Zero external dependencies.
*/
import { readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, readFile } from 'node:fs';
import { readFile as readFileAsync } from 'node:fs/promises';
import { join, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
const MAX_BACKUPS = 10;
/**
* Get the backup root directory path.
* @returns {string}
*/
export function getBackupDir() {
return BACKUP_ROOT;
}
/**
* Generate a timestamp-based backup ID.
* @returns {string} Format: YYYYMMDD_HHMMSS
*/
export function generateBackupId() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return `${y}${m}${d}_${h}${min}${s}`;
}
/**
* Create a safe filename from a file path (replace path separators with _).
* @param {string} filePath
* @returns {string}
*/
export function safeFileName(filePath) {
return filePath.replace(/[\\\/]/g, '_');
}
/**
* Calculate SHA-256 checksum of a buffer or string.
* @param {Buffer|string} content
* @returns {string}
*/
export function checksum(content) {
return createHash('sha256').update(content).digest('hex');
}
/**
* Create a backup of the specified files.
* @param {string[]} files - Array of absolute file paths to back up
* @param {object} [opts]
* @param {string} [opts.backupId] - Override backup ID (for testing)
* @returns {{ backupId: string, backupPath: string, manifest: object }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
const backupPath = join(BACKUP_ROOT, backupId);
const filesDir = join(backupPath, 'files');
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
for (const file of files) {
if (!existsSync(file)) continue;
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
const content = readFileSync(file);
const hash = checksum(content);
const sizeBytes = statSync(file).size;
manifestFiles.push({
originalPath: file,
backupPath: `./files/${safeName}`,
checksum: hash,
sizeBytes,
});
}
const manifest = {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
};
// Write manifest as YAML-like format
const manifestYaml = serializeManifest(manifest);
writeFileSync(join(backupPath, 'manifest.yaml'), manifestYaml);
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest };
}
/**
* Serialize manifest to YAML-like format.
* @param {object} manifest
* @returns {string}
*/
function serializeManifest(manifest) {
let yaml = `created_at: "${manifest.created_at}"\n`;
yaml += `backup_id: "${manifest.backup_id}"\n`;
yaml += `files:\n`;
for (const f of manifest.files) {
yaml += ` - original_path: "${f.originalPath}"\n`;
yaml += ` backup_path: "${f.backupPath}"\n`;
yaml += ` checksum: "${f.checksum}"\n`;
yaml += ` size_bytes: ${f.sizeBytes}\n`;
}
return yaml;
}
/**
* Parse a manifest.yaml file content.
* @param {string} content
* @returns {object}
*/
export function parseManifest(content) {
const result = { created_at: '', backup_id: '', files: [] };
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
if (createdMatch) result.created_at = createdMatch[1];
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
if (idMatch) result.backup_id = idMatch[1];
// Parse file entries
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
for (const block of fileBlocks) {
const origMatch = block.match(/^\s*"([^"]+)"/);
const bpMatch = block.match(/backup_path:\s*"([^"]+)"/);
const csMatch = block.match(/checksum:\s*"([^"]+)"/);
const szMatch = block.match(/size_bytes:\s*(\d+)/);
if (origMatch && bpMatch && csMatch) {
result.files.push({
originalPath: origMatch[1],
backupPath: bpMatch[1],
checksum: csMatch[1],
sizeBytes: szMatch ? parseInt(szMatch[1], 10) : 0,
});
}
}
return result;
}
/**
* Remove old backups beyond MAX_BACKUPS.
*/
function cleanupOldBackups() {
if (!existsSync(BACKUP_ROOT)) return;
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort();
if (dirs.length > MAX_BACKUPS) {
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
for (const dir of toDelete) {
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
}
}
}
export { MAX_BACKUPS };

View file

@ -0,0 +1,124 @@
/**
* Baseline manager for config-audit.
* Stores and retrieves scanner envelopes as named baselines.
* Zero external dependencies.
*/
import { readFile, writeFile, readdir, unlink, mkdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const BASELINES_DIR = join(homedir(), '.config-audit', 'baselines');
/**
* Get the baselines directory path.
* @returns {string}
*/
export function getBaselinesDir() {
return BASELINES_DIR;
}
/**
* Save a scanner envelope as a named baseline.
* @param {object} envelope - Full envelope from scan-orchestrator
* @param {string} [name='default'] - Baseline name
* @returns {Promise<{ path: string, name: string }>}
*/
export async function saveBaseline(envelope, name = 'default') {
await mkdir(BASELINES_DIR, { recursive: true });
const enriched = {
...envelope,
_baseline: {
saved_at: new Date().toISOString(),
target_path: envelope.meta?.target || '',
finding_count: envelope.aggregate?.total_findings || 0,
score: avgScore(envelope),
},
};
const filePath = join(BASELINES_DIR, `${name}.json`);
await writeFile(filePath, JSON.stringify(enriched, null, 2), 'utf-8');
return { path: filePath, name };
}
/**
* Load a named baseline.
* @param {string} [name='default'] - Baseline name
* @returns {Promise<object|null>} Envelope or null if not found
*/
export async function loadBaseline(name = 'default') {
const filePath = join(BASELINES_DIR, `${name}.json`);
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* List all saved baselines.
* @returns {Promise<{ baselines: Array<{ name: string, savedAt: string, targetPath: string, findingCount: number, score: number }> }>}
*/
export async function listBaselines() {
try {
await stat(BASELINES_DIR);
} catch {
return { baselines: [] };
}
const entries = await readdir(BASELINES_DIR);
const baselines = [];
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
const name = entry.replace(/\.json$/, '');
const filePath = join(BASELINES_DIR, entry);
try {
const content = await readFile(filePath, 'utf-8');
const data = JSON.parse(content);
const meta = data._baseline || {};
baselines.push({
name,
savedAt: meta.saved_at || '',
targetPath: meta.target_path || '',
findingCount: meta.finding_count || 0,
score: meta.score || 0,
});
} catch {
// Skip corrupt baselines
baselines.push({ name, savedAt: '', targetPath: '', findingCount: 0, score: 0 });
}
}
return { baselines };
}
/**
* Delete a named baseline.
* @param {string} name - Baseline name
* @returns {Promise<{ deleted: boolean }>}
*/
export async function deleteBaseline(name) {
const filePath = join(BASELINES_DIR, `${name}.json`);
try {
await unlink(filePath);
return { deleted: true };
} catch {
return { deleted: false };
}
}
// --- Internal helpers ---
function avgScore(envelope) {
const scanners = envelope.scanners || [];
if (scanners.length === 0) return 0;
// Simple: count findings as proxy for score
const total = envelope.aggregate?.total_findings || 0;
// Lower findings = higher score. Cap at 100.
return Math.max(0, 100 - total * 3);
}

View file

@ -0,0 +1,287 @@
/**
* Diff engine for config-audit.
* Compares two scanner envelopes (baseline vs current) to detect drift.
* Zero external dependencies.
*/
import { scoreByArea } from './scoring.mjs';
import { gradeFromPassRate } from './severity.mjs';
/**
* Diff two scanner envelopes.
* @param {object} baseline - Full envelope from scan-orchestrator
* @param {object} current - Full envelope from scan-orchestrator
* @returns {object} Diff result with new, resolved, unchanged, moved findings + score changes
*/
export function diffEnvelopes(baseline, current) {
const baseFindings = extractFindings(baseline);
const currFindings = extractFindings(current);
// Build lookup maps keyed by scanner+title+file
const baseByKey = groupByKey(baseFindings);
const currByKey = groupByKey(currFindings);
// Also build maps by scanner+title (ignoring file) for moved detection
const baseByScannerTitle = groupByScannerTitle(baseFindings);
const currByScannerTitle = groupByScannerTitle(currFindings);
const newFindings = [];
const resolvedFindings = [];
const unchangedFindings = [];
const movedFindings = [];
const matchedBaseKeys = new Set();
const matchedCurrKeys = new Set();
// Pass 1: exact matches (scanner+title+file)
for (const [key, currList] of currByKey.entries()) {
const baseList = baseByKey.get(key);
if (baseList && baseList.length > 0) {
// Match as many as possible
const matchCount = Math.min(baseList.length, currList.length);
for (let i = 0; i < matchCount; i++) {
unchangedFindings.push(currList[i]);
}
// Extra in current = new
for (let i = matchCount; i < currList.length; i++) {
newFindings.push(currList[i]);
}
matchedBaseKeys.add(key);
matchedCurrKeys.add(key);
}
}
// Pass 2: find moved findings (same scanner+title, different file)
const resolvedCandidates = [];
const newCandidates = [];
for (const [key, baseList] of baseByKey.entries()) {
if (!matchedBaseKeys.has(key)) {
resolvedCandidates.push(...baseList);
} else {
// Any extras in baseline beyond matched count
const currList = currByKey.get(key) || [];
const matchCount = Math.min(baseList.length, currList.length);
for (let i = matchCount; i < baseList.length; i++) {
resolvedCandidates.push(baseList[i]);
}
}
}
for (const [key, currList] of currByKey.entries()) {
if (!matchedCurrKeys.has(key)) {
newCandidates.push(...currList);
}
}
// Try to pair resolved candidates with new candidates as "moved"
const usedResolved = new Set();
const usedNew = new Set();
for (let i = 0; i < newCandidates.length; i++) {
const curr = newCandidates[i];
for (let j = 0; j < resolvedCandidates.length; j++) {
if (usedResolved.has(j)) continue;
const base = resolvedCandidates[j];
if (base.scanner === curr.scanner && base.title === curr.title && base.file !== curr.file) {
movedFindings.push({ from: base, to: curr });
usedResolved.add(j);
usedNew.add(i);
break;
}
}
}
// Remaining unmatched
for (let i = 0; i < resolvedCandidates.length; i++) {
if (!usedResolved.has(i)) resolvedFindings.push(resolvedCandidates[i]);
}
for (let i = 0; i < newCandidates.length; i++) {
if (!usedNew.has(i)) newFindings.push(newCandidates[i]);
}
// Score changes
const baseAreas = scoreByArea(baseline.scanners || []);
const currAreas = scoreByArea(current.scanners || []);
const baseAvg = avgScore(baseAreas.areas);
const currAvg = avgScore(currAreas.areas);
const scoreChange = {
before: { score: baseAvg, grade: gradeFromPassRate(baseAvg) },
after: { score: currAvg, grade: gradeFromPassRate(currAvg) },
delta: currAvg - baseAvg,
};
// Per-area changes
const areaChanges = buildAreaChanges(baseAreas.areas, currAreas.areas);
// Summary
const totalBefore = baseFindings.length;
const totalAfter = currFindings.length;
const newCount = newFindings.length;
const resolvedCount = resolvedFindings.length;
let trend = 'stable';
if (resolvedCount > newCount) trend = 'improving';
else if (newCount > resolvedCount) trend = 'degrading';
return {
newFindings,
resolvedFindings,
unchangedFindings,
movedFindings,
scoreChange,
areaChanges,
summary: {
totalBefore,
totalAfter,
newCount,
resolvedCount,
trend,
},
};
}
/**
* Format a diff result into a human-readable terminal report.
* @param {object} diff - Output from diffEnvelopes()
* @returns {string}
*/
export function formatDiffReport(diff) {
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Config-Audit Drift Report');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push('');
// Trend
const trendIcon = diff.summary.trend === 'improving' ? '↑'
: diff.summary.trend === 'degrading' ? '↓' : '→';
const trendLabel = diff.summary.trend.charAt(0).toUpperCase() + diff.summary.trend.slice(1);
lines.push(` Trend: ${trendIcon} ${trendLabel}`);
lines.push('');
// Score
const sc = diff.scoreChange;
const deltaSign = sc.delta > 0 ? '+' : '';
lines.push(` Score: ${sc.before.grade} (${sc.before.score}) → ${sc.after.grade} (${sc.after.score}) ${trendIcon} ${deltaSign}${sc.delta} points`);
lines.push('');
// New findings
if (diff.newFindings.length > 0) {
lines.push(` New findings (${diff.newFindings.length}):`);
for (const f of diff.newFindings) {
const fileInfo = f.file ? ` (${f.file})` : '';
lines.push(` - [${f.severity}] ${f.title}${fileInfo}`);
}
lines.push('');
}
// Resolved
if (diff.resolvedFindings.length > 0) {
lines.push(` Resolved (${diff.resolvedFindings.length}):`);
for (const f of diff.resolvedFindings) {
lines.push(` - [${f.severity}] ${f.title}`);
}
lines.push('');
}
// Moved
if (diff.movedFindings.length > 0) {
lines.push(` Moved (${diff.movedFindings.length}):`);
for (const m of diff.movedFindings) {
lines.push(` - [${m.from.severity}] ${m.from.title}: ${m.from.file}${m.to.file}`);
}
lines.push('');
}
// Area changes (only show areas with delta != 0)
const changedAreas = diff.areaChanges.filter(a => a.delta !== 0);
if (changedAreas.length > 0) {
lines.push(' Area changes:');
for (const a of changedAreas) {
const sign = a.delta > 0 ? '↑' : '↓';
const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
const padding = '.'.repeat(Math.max(1, 20 - a.name.length));
lines.push(` ${a.name} ${padding} ${a.before.grade} (${a.before.score}) → ${a.after.grade} (${a.after.score}) ${sign} ${deltaStr}`);
}
lines.push('');
}
// Unchanged summary
if (diff.unchangedFindings.length > 0) {
lines.push(` Unchanged: ${diff.unchangedFindings.length} finding(s)`);
lines.push('');
}
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
// --- Internal helpers ---
function extractFindings(envelope) {
const findings = [];
for (const scanner of (envelope.scanners || [])) {
for (const f of (scanner.findings || [])) {
findings.push(f);
}
}
return findings;
}
function findingKey(f) {
return `${f.scanner}::${f.title}::${f.file || ''}`;
}
function scannerTitleKey(f) {
return `${f.scanner}::${f.title}`;
}
function groupByKey(findings) {
const map = new Map();
for (const f of findings) {
const key = findingKey(f);
if (!map.has(key)) map.set(key, []);
map.get(key).push(f);
}
return map;
}
function groupByScannerTitle(findings) {
const map = new Map();
for (const f of findings) {
const key = scannerTitleKey(f);
if (!map.has(key)) map.set(key, []);
map.get(key).push(f);
}
return map;
}
function avgScore(areas) {
if (areas.length === 0) return 0;
return Math.round(areas.reduce((s, a) => s + a.score, 0) / areas.length);
}
function buildAreaChanges(baseAreas, currAreas) {
const baseMap = new Map(baseAreas.map(a => [a.name, a]));
const currMap = new Map(currAreas.map(a => [a.name, a]));
const allNames = new Set([...baseMap.keys(), ...currMap.keys()]);
const changes = [];
for (const name of allNames) {
const before = baseMap.get(name) || { score: 0, grade: 'F' };
const after = currMap.get(name) || { score: 0, grade: 'F' };
changes.push({
name,
before: { score: before.score, grade: before.grade },
after: { score: after.score, grade: after.grade },
delta: after.score - before.score,
});
}
return changes;
}

View file

@ -0,0 +1,308 @@
/**
* 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',
]);
/** 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/
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
*
* @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 */ }
}
return { files, skipped: skippedRef.count };
}
/**
* 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
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
*/
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 */ }
}
return { files: allFiles, skipped: totalSkipped };
}
/**
* 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;
}
}

View file

@ -0,0 +1,121 @@
/**
* Finding and result builders for config-audit scanners.
* Finding IDs: CA-{SCANNER}-{NNN} (e.g. CA-CML-001)
* Zero external dependencies.
*/
import { riskScore, riskBand, verdict } from './severity.mjs';
let findingCounter = 0;
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
export function resetCounter() {
findingCounter = 0;
}
/**
* Create a finding object with auto-incremented ID.
* @param {object} opts
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
* @param {string} opts.severity - critical | high | medium | low | info
* @param {string} opts.title
* @param {string} opts.description
* @param {string} [opts.file] - file path where finding was detected
* @param {number} [opts.line] - line number
* @param {string} [opts.evidence] - relevant snippet
* @param {string} [opts.category] - quality category
* @param {string} [opts.recommendation] - suggested fix
* @param {boolean} [opts.autoFixable] - can be auto-fixed
* @returns {object}
*/
export function finding(opts) {
findingCounter++;
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
return {
id,
scanner: opts.scanner,
severity: opts.severity,
title: opts.title,
description: opts.description,
file: opts.file || null,
line: opts.line || null,
evidence: opts.evidence || null,
category: opts.category || null,
recommendation: opts.recommendation || null,
autoFixable: opts.autoFixable || false,
};
}
/**
* Create a scanner result envelope.
* @param {string} scannerName - 3-letter prefix
* @param {'ok' | 'error' | 'skipped'} status
* @param {object[]} findings
* @param {number} filesScanned
* @param {number} durationMs
* @param {string} [errorMsg]
* @returns {object}
*/
export function scannerResult(scannerName, status, findings, filesScanned, durationMs, errorMsg) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of findings) {
if (counts[f.severity] !== undefined) {
counts[f.severity]++;
}
}
const result = {
scanner: scannerName,
status,
files_scanned: filesScanned,
duration_ms: durationMs,
findings,
counts,
};
if (errorMsg) result.error = errorMsg;
return result;
}
/**
* Create the top-level output envelope combining all scanner results.
* @param {string} targetPath
* @param {object[]} scannerResults
* @param {number} totalDurationMs
* @returns {object}
*/
export function envelope(targetPath, scannerResults, totalDurationMs) {
const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
let totalFindings = 0;
let scannersOk = 0;
let scannersError = 0;
let scannersSkipped = 0;
for (const r of scannerResults) {
for (const sev of Object.keys(aggregate)) {
aggregate[sev] += (r.counts[sev] || 0);
}
totalFindings += r.findings.length;
if (r.status === 'ok') scannersOk++;
else if (r.status === 'error') scannersError++;
else if (r.status === 'skipped') scannersSkipped++;
}
return {
meta: {
target: targetPath,
timestamp: new Date().toISOString(),
version: '2.2.0',
tool: 'config-audit',
},
scanners: scannerResults,
aggregate: {
total_findings: totalFindings,
counts: aggregate,
risk_score: riskScore(aggregate),
risk_band: riskBand(riskScore(aggregate)),
verdict: verdict(aggregate),
scanners_ok: scannersOk,
scanners_error: scannersError,
scanners_skipped: scannersSkipped,
},
};
}

View file

@ -0,0 +1,278 @@
/**
* Unified report generator for config-audit.
* Produces markdown reports from posture, drift, and plugin health results.
* Template strings are embedded in JS no separate .md files to parse.
* Zero external dependencies.
*/
const MAX_FINDINGS_PER_SCANNER = 10;
const MAX_REPORT_LINES = 500;
/**
* Generate a posture report in markdown.
* @param {object} postureResult - Output from runPosture()
* @returns {string}
*/
export function generatePostureReport(postureResult) {
const {
areas, overallGrade, scannerEnvelope,
} = postureResult;
const opportunityCount = postureResult.opportunityCount ?? 0;
// Quality areas only (exclude Feature Coverage)
const qualityAreas = areas.filter(a => a.name !== 'Feature Coverage');
const avgScore = qualityAreas.length > 0
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
: 0;
const lines = [];
const ts = scannerEnvelope?.meta?.timestamp || new Date().toISOString();
const target = scannerEnvelope?.meta?.target || 'unknown';
lines.push('## Health Assessment');
lines.push('');
lines.push(`> **Date:** ${ts.split('T')[0]} `);
lines.push(`> **Target:** \`${target}\` `);
lines.push('');
// Score summary
lines.push('### Score Summary');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Health Grade | **${overallGrade}** (${avgScore}/100) |`);
lines.push(`| Areas Scanned | ${qualityAreas.length} |`);
if (opportunityCount > 0) {
lines.push(`| Opportunities | ${opportunityCount} features available |`);
}
lines.push('');
// Area breakdown
lines.push('### Area Breakdown');
lines.push('');
lines.push('| Area | Grade | Score | Findings |');
lines.push('|------|-------|-------|----------|');
for (const a of qualityAreas) {
lines.push(`| ${a.name} | ${a.grade} | ${a.score} | ${a.findingCount} |`);
}
lines.push('');
// Opportunities pointer (replaces Top Actions)
if (opportunityCount > 0) {
lines.push(`> Run \`/config-audit feature-gap\` for ${opportunityCount} context-aware recommendations.`);
lines.push('');
}
// Findings per scanner (collapsed)
if (scannerEnvelope?.scanners) {
lines.push('### Findings by Scanner');
lines.push('');
for (const sr of scannerEnvelope.scanners) {
if (sr.findings.length === 0) continue;
lines.push(`<details>`);
lines.push(`<summary>${sr.scanner}${sr.findings.length} finding(s)</summary>`);
lines.push('');
const show = sr.findings.slice(0, MAX_FINDINGS_PER_SCANNER);
for (const f of show) {
lines.push(`- \`[${f.severity}]\` ${f.title}${f.file ? ` (${f.file})` : ''}`);
}
if (sr.findings.length > MAX_FINDINGS_PER_SCANNER) {
lines.push(`- _...and ${sr.findings.length - MAX_FINDINGS_PER_SCANNER} more_`);
}
lines.push('');
lines.push('</details>');
lines.push('');
}
}
return lines.join('\n');
}
/**
* Generate a drift report in markdown.
* @param {object} diffResult - Output from diffEnvelopes()
* @param {string} baselineName - Name of baseline used
* @returns {string}
*/
export function generateDriftReport(diffResult, baselineName) {
const lines = [];
const { summary, scoreChange, newFindings, resolvedFindings, areaChanges } = diffResult;
const trendIcon = summary.trend === 'improving' ? '&#x2191;'
: summary.trend === 'degrading' ? '&#x2193;' : '&#x2192;';
const trendLabel = summary.trend.charAt(0).toUpperCase() + summary.trend.slice(1);
lines.push('## Drift Report');
lines.push('');
lines.push(`> **Baseline:** \`${baselineName}\` `);
lines.push(`> **Trend:** ${trendIcon} ${trendLabel} `);
lines.push('');
// Score delta
const sc = scoreChange;
const deltaSign = sc.delta > 0 ? '+' : '';
lines.push('### Score Change');
lines.push('');
lines.push(`**${sc.before.grade}** (${sc.before.score}) ${trendIcon} **${sc.after.grade}** (${sc.after.score}) — ${deltaSign}${sc.delta} points`);
lines.push('');
// New findings
if (newFindings.length > 0) {
lines.push('### New Findings');
lines.push('');
lines.push('| Severity | Title | File |');
lines.push('|----------|-------|------|');
for (const f of newFindings.slice(0, 20)) {
lines.push(`| \`${f.severity}\` | ${f.title} | ${f.file || '-'} |`);
}
if (newFindings.length > 20) {
lines.push(`| | _...and ${newFindings.length - 20} more_ | |`);
}
lines.push('');
}
// Resolved findings
if (resolvedFindings.length > 0) {
lines.push('### Resolved Findings');
lines.push('');
lines.push('| Severity | Title |');
lines.push('|----------|-------|');
for (const f of resolvedFindings.slice(0, 20)) {
lines.push(`| \`${f.severity}\` | ${f.title} |`);
}
if (resolvedFindings.length > 20) {
lines.push(`| | _...and ${resolvedFindings.length - 20} more_ |`);
}
lines.push('');
}
// Area changes
const changed = (areaChanges || []).filter(a => a.delta !== 0);
if (changed.length > 0) {
lines.push('### Area Changes');
lines.push('');
lines.push('| Area | Before | After | Delta |');
lines.push('|------|--------|-------|-------|');
for (const a of changed) {
const sign = a.delta > 0 ? '+' : '';
lines.push(`| ${a.name} | ${a.before.grade} (${a.before.score}) | ${a.after.grade} (${a.after.score}) | ${sign}${a.delta} |`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Generate a plugin health report in markdown.
* @param {object} scanResult - Scanner result from plugin-health-scanner scan()
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
* @returns {string}
*/
export function generatePluginHealthReport(scanResult, pluginResults) {
const lines = [];
lines.push('## Plugin Health');
lines.push('');
if (!pluginResults || pluginResults.length === 0) {
lines.push('_No plugins found._');
lines.push('');
return lines.join('\n');
}
// Plugin summary table
lines.push('| Plugin | Grade | Score | Commands | Agents | Issues |');
lines.push('|--------|-------|-------|----------|--------|--------|');
for (const p of pluginResults) {
const issueCount = p.findings.length;
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
lines.push(`| ${p.name} | ${grade} | ${score} | ${p.commandCount} | ${p.agentCount} | ${issueCount} |`);
}
lines.push('');
// Per-plugin findings
for (const p of pluginResults) {
if (p.findings.length === 0) continue;
lines.push(`<details>`);
lines.push(`<summary>${p.name}${p.findings.length} issue(s)</summary>`);
lines.push('');
for (const f of p.findings.slice(0, MAX_FINDINGS_PER_SCANNER)) {
lines.push(`- \`[${f.severity}]\` ${f.title}`);
}
lines.push('');
lines.push('</details>');
lines.push('');
}
// Cross-plugin issues (from scanResult.findings where title contains "Cross-plugin")
const crossPlugin = (scanResult?.findings || []).filter(f => f.title.includes('Cross-plugin'));
if (crossPlugin.length > 0) {
lines.push('### Cross-Plugin Issues');
lines.push('');
for (const f of crossPlugin) {
lines.push(`- \`[${f.severity}]\` ${f.title}: ${f.description}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Generate a unified full report combining all sections.
* Each input is optional (null = skip that section).
* @param {object|null} postureResult - From runPosture()
* @param {object|null} driftResult - { diff, baselineName } from diffEnvelopes()
* @param {object|null} pluginHealthResult - { scanResult, pluginResults } from plugin-health-scanner
* @returns {string}
*/
export function generateFullReport(postureResult, driftResult, pluginHealthResult) {
const lines = [];
lines.push('# Config-Audit Report');
lines.push('');
lines.push(`_Generated: ${new Date().toISOString().split('T')[0]}_`);
lines.push('');
lines.push('---');
lines.push('');
if (postureResult) {
lines.push(generatePostureReport(postureResult));
lines.push('---');
lines.push('');
}
if (driftResult) {
lines.push(generateDriftReport(driftResult.diff, driftResult.baselineName));
lines.push('---');
lines.push('');
}
if (pluginHealthResult) {
lines.push(generatePluginHealthReport(
pluginHealthResult.scanResult,
pluginHealthResult.pluginResults,
));
lines.push('---');
lines.push('');
}
if (!postureResult && !driftResult && !pluginHealthResult) {
lines.push('_No data provided for report._');
lines.push('');
}
// Truncate if over limit
const result = lines.join('\n');
const resultLines = result.split('\n');
if (resultLines.length > MAX_REPORT_LINES) {
const truncated = resultLines.slice(0, MAX_REPORT_LINES);
truncated.push('');
truncated.push(`_Report truncated at ${MAX_REPORT_LINES} lines. Run individual reports for full details._`);
return truncated.join('\n');
}
return result;
}

View file

@ -0,0 +1,310 @@
/**
* Scoring, maturity, and posture assessment for config-audit.
* Zero external dependencies.
*/
import { gradeFromPassRate } from './severity.mjs';
// --- Tier weights for utilization calculation ---
const TIER_WEIGHTS = { t1: 3, t2: 2, t3: 1, t4: 1 };
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
const TOTAL_DIMENSIONS = 25;
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
0,
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
/**
* Calculate weighted utilization from GAP scanner findings.
* @param {object[]} gapFindings - Array of GAP scanner findings (each has .category = t1|t2|t3|t4)
* @param {number} [totalDimensions=25]
* @returns {{ score: number, overhang: number }}
*/
export function calculateUtilization(gapFindings, totalDimensions = TOTAL_DIMENSIONS) {
// Count gaps per tier
const gapsByTier = { t1: 0, t2: 0, t3: 0, t4: 0 };
for (const f of gapFindings) {
const tier = f.category;
if (tier in gapsByTier) gapsByTier[tier]++;
}
// Present (non-gap) weight
let presentWeight = 0;
for (const [tier, totalCount] of Object.entries(TIER_COUNTS)) {
const presentCount = totalCount - gapsByTier[tier];
presentWeight += presentCount * TIER_WEIGHTS[tier];
}
const score = Math.round((presentWeight / MAX_WEIGHTED) * 100);
return { score, overhang: 100 - score };
}
// --- Maturity levels ---
const MATURITY_LEVELS = [
{ level: 0, name: 'Bare', description: 'No CLAUDE.md, default everything' },
{ level: 1, name: 'Configured', description: 'CLAUDE.md + basic settings' },
{ level: 2, name: 'Structured', description: 'Rules, skills, hooks' },
{ level: 3, name: 'Automated', description: 'MCP, custom agents, diverse hooks' },
{ level: 4, name: 'Governed', description: 'Plugins, managed settings, full monitoring' },
];
/**
* Determine config maturity level (threshold-based: highest level where ALL requirements met).
* @param {object[]} gapFindings - GAP scanner findings
* @param {{ files: Array<{ type: string, absPath?: string, scope?: string }> }} discovery
* @returns {{ level: number, name: string, description: string }}
*/
export function determineMaturityLevel(gapFindings, discovery) {
const gapIds = new Set(gapFindings.map(f => {
// Extract the gap check id from the title — match against known titles
return findGapId(f);
}));
const has = (id) => !gapIds.has(id); // feature is present if NOT in gaps
// Level 1: CLAUDE.md present
if (!has('t1_1')) return MATURITY_LEVELS[0];
// Level 2: Level 1 + permissions + hooks + (modular OR path-rules)
const level2 = has('t1_2') && has('t1_3') && (has('t2_2') || has('t2_3'));
if (!level2) return MATURITY_LEVELS[1];
// Level 3: Level 2 + MCP + hook diversity + custom subagents
const level3 = has('t1_5') && has('t2_5') && has('t2_6');
if (!level3) return MATURITY_LEVELS[2];
// Level 4: Level 3 + project MCP in git + custom plugin
const level4 = has('t4_1') && has('t4_2');
if (!level4) return MATURITY_LEVELS[3];
return MATURITY_LEVELS[4];
}
/**
* Map a GAP finding to its gap check ID based on known titleid mapping.
* @param {object} finding
* @returns {string}
*/
function findGapId(finding) {
return TITLE_TO_ID[finding.title] || 'unknown';
}
/** Title→ID mapping for all 25 gap checks */
const TITLE_TO_ID = {
'No CLAUDE.md file': 't1_1',
'No permissions configured': 't1_2',
'No hooks configured': 't1_3',
'No custom skills or commands': 't1_4',
'No MCP servers configured': 't1_5',
'Settings only at one scope': 't2_1',
'CLAUDE.md not modular': 't2_2',
'No path-scoped rules': 't2_3',
'Auto-memory explicitly disabled': 't2_4',
'Low hook diversity': 't2_5',
'No custom subagents': 't2_6',
'No model configuration': 't2_7',
'No status line configured': 't3_1',
'No custom keybindings': 't3_2',
'Using default output style': 't3_3',
'No worktree workflow': 't3_4',
'No advanced skill frontmatter': 't3_5',
'No subagent isolation': 't3_6',
'No dynamic skill context': 't3_7',
'No autoMode classifier': 't3_8',
'No project .mcp.json in git': 't4_1',
'No custom plugin': 't4_2',
'Agent teams not enabled': 't4_3',
'No managed settings': 't4_4',
'No LSP plugins': 't4_5',
};
// --- Segments ---
const SEGMENTS = [
{ min: 81, segment: 'Top Performer', description: 'Exceptional configuration — leveraging most of Claude Code\'s capabilities' },
{ min: 65, segment: 'Strong', description: 'Well-configured — using advanced features effectively' },
{ min: 45, segment: 'Competent', description: 'Solid foundation — room to leverage more features' },
{ min: 25, segment: 'Developing', description: 'Basic setup — significant features untapped' },
{ min: 0, segment: 'Beginner', description: 'Minimal configuration — most capabilities unused' },
];
/**
* Determine segment from utilization score.
* @param {number} score - 0-100
* @param {number} [_maturityLevel] - unused, kept for API compatibility
* @returns {{ segment: string, description: string }}
*/
export function determineSegment(score, _maturityLevel) {
for (const s of SEGMENTS) {
if (score >= s.min) return { segment: s.segment, description: s.description };
}
return SEGMENTS[SEGMENTS.length - 1];
}
// --- Area scoring ---
const SCANNER_AREA_MAP = {
CML: 'CLAUDE.md',
SET: 'Settings',
HKV: 'Hooks',
RUL: 'Rules',
MCP: 'MCP',
IMP: 'Imports',
CNF: 'Conflicts',
GAP: 'Feature Coverage',
};
/**
* Score per config area from scanner results.
* @param {object[]} scannerResults - Array of scanner result objects from envelope.scanners
* @returns {{ areas: Array<{ name: string, grade: string, score: number, findingCount: number }>, overallGrade: string }}
*/
export function scoreByArea(scannerResults) {
const areas = [];
for (const result of scannerResults) {
const name = SCANNER_AREA_MAP[result.scanner] || result.scanner;
const findingCount = result.findings.length;
let score;
if (result.scanner === 'GAP') {
// Feature coverage: utilization-based
const util = calculateUtilization(result.findings);
score = util.score;
} else {
// Quality-based: fewer findings = higher pass rate
// Use a reasonable max checks per scanner for pass rate
const maxChecks = Math.max(findingCount + 5, 10);
const passRate = ((maxChecks - findingCount) / maxChecks) * 100;
score = Math.round(passRate);
}
const grade = gradeFromPassRate(score);
areas.push({ name, grade, score, findingCount });
}
// Overall grade: quality areas only (exclude GAP — feature coverage is informational, not a quality issue)
const qualityAreas = areas.filter(a => a.name !== 'Feature Coverage');
const totalScore = qualityAreas.reduce((sum, a) => sum + a.score, 0);
const avgScore = qualityAreas.length > 0 ? Math.round(totalScore / qualityAreas.length) : 0;
const overallGrade = gradeFromPassRate(avgScore);
return { areas, overallGrade };
}
/**
* Derive top 3 actions from GAP findings (T1 first, then T2).
* @param {object[]} gapFindings
* @returns {string[]}
*/
export function topActions(gapFindings) {
const tierOrder = ['t1', 't2', 't3', 't4'];
const sorted = [...gapFindings].sort(
(a, b) => tierOrder.indexOf(a.category) - tierOrder.indexOf(b.category),
);
return sorted.slice(0, 3).map(f => f.recommendation);
}
/**
* Generate a terminal-friendly scorecard string (v2 format kept for backward compat).
* @param {{ areas: Array<{ name: string, grade: string, score: number }>, overallGrade: string }} areaScores
* @param {{ score: number, overhang: number }} utilization
* @param {{ level: number, name: string }} maturity
* @param {{ segment: string }} segment
* @param {string[]} actions
* @returns {string}
* @deprecated Use generateHealthScorecard for v3+ terminal output
*/
export function generateScorecard(areaScores, utilization, maturity, segment, actions) {
// Bug fix: exclude GAP from displayed avgScore (was inconsistent with overallGrade)
const qualityAreas = areaScores.areas.filter(a => a.name !== 'Feature Coverage');
const avgScore = qualityAreas.length > 0
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
: 0;
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Config-Audit Posture Score');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push('');
lines.push(` Overall: ${areaScores.overallGrade} (${avgScore}/100) Maturity: Level ${maturity.level} (${maturity.name})`);
lines.push(` Segment: ${segment.segment} Utilization: ${utilization.score}%`);
lines.push('');
lines.push(' Area Scores');
lines.push(' ───────────');
// Format areas in 2-column layout
const areas = areaScores.areas;
for (let i = 0; i < areas.length; i += 2) {
const left = areas[i];
const right = areas[i + 1];
const leftStr = ` ${left.name} ${'.'.repeat(Math.max(1, 20 - left.name.length))} ${left.grade} (${left.score})`;
if (right) {
const rightStr = `${right.name} ${'.'.repeat(Math.max(1, 20 - right.name.length))} ${right.grade} (${right.score})`;
lines.push(`${leftStr.padEnd(35)}${rightStr}`);
} else {
lines.push(leftStr);
}
}
if (actions.length > 0) {
lines.push('');
lines.push(' Top 3 Actions');
lines.push(' ─────────────');
for (let i = 0; i < actions.length; i++) {
lines.push(` ${i + 1}. ${actions[i]}`);
}
}
lines.push('');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
/**
* Generate a v3 health-focused terminal scorecard.
* Shows only the 7 quality areas no utilization, maturity, or segment.
* @param {{ areas: Array<{ name: string, grade: string, score: number }>, overallGrade: string }} areaScores
* @param {number} opportunityCount - Number of GAP findings (shown as opportunity count)
* @returns {string}
*/
export function generateHealthScorecard(areaScores, opportunityCount) {
const qualityAreas = areaScores.areas.filter(a => a.name !== 'Feature Coverage');
const avgScore = qualityAreas.length > 0
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
: 0;
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Config-Audit Health Score');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push('');
lines.push(` Health: ${areaScores.overallGrade} (${avgScore}/100) ${qualityAreas.length} areas scanned`);
lines.push('');
lines.push(' Area Scores');
lines.push(' ───────────');
// Format areas in 2-column layout (quality areas only)
for (let i = 0; i < qualityAreas.length; i += 2) {
const left = qualityAreas[i];
const right = qualityAreas[i + 1];
const leftStr = ` ${left.name} ${'.'.repeat(Math.max(1, 20 - left.name.length))} ${left.grade} (${left.score})`;
if (right) {
const rightStr = `${right.name} ${'.'.repeat(Math.max(1, 20 - right.name.length))} ${right.grade} (${right.score})`;
lines.push(`${leftStr.padEnd(35)}${rightStr}`);
} else {
lines.push(leftStr);
}
}
if (opportunityCount > 0) {
lines.push('');
lines.push(` ${opportunityCount} ${opportunityCount === 1 ? 'opportunity' : 'opportunities'} available — run /config-audit feature-gap for recommendations`);
}
lines.push('');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };

View file

@ -0,0 +1,75 @@
/**
* Severity constants, risk scoring, and verdict logic for config-audit scanners.
* Zero external dependencies.
*/
export const SEVERITY = Object.freeze({
critical: 'critical',
high: 'high',
medium: 'medium',
low: 'low',
info: 'info',
});
const WEIGHTS = { critical: 25, high: 10, medium: 4, low: 1, info: 0 };
/**
* Calculate a 0-100 risk score from severity counts.
* @param {{ critical?: number, high?: number, medium?: number, low?: number, info?: number }} counts
* @returns {number}
*/
export function riskScore(counts) {
let score = 0;
for (const [sev, weight] of Object.entries(WEIGHTS)) {
score += (counts[sev] || 0) * weight;
}
return Math.min(score, 100);
}
/**
* Determine overall verdict from severity counts.
* @param {{ critical?: number, high?: number, medium?: number, low?: number, info?: number }} counts
* @returns {'FAIL' | 'WARNING' | 'PASS'}
*/
export function verdict(counts) {
const score = riskScore(counts);
if ((counts.critical || 0) >= 1 || score >= 61) return 'FAIL';
if ((counts.high || 0) >= 1 || score >= 21) return 'WARNING';
return 'PASS';
}
/**
* Map a risk score to a human-readable band.
* @param {number} score
* @returns {'Low' | 'Medium' | 'High' | 'Critical' | 'Extreme'}
*/
export function riskBand(score) {
if (score <= 10) return 'Low';
if (score <= 30) return 'Medium';
if (score <= 60) return 'High';
if (score <= 80) return 'Critical';
return 'Extreme';
}
/**
* Grade from a quality pass rate (0-100%).
* @param {number} passRate - 0-100
* @returns {'A' | 'B' | 'C' | 'D' | 'F'}
*/
export function gradeFromPassRate(passRate) {
if (passRate >= 90) return 'A';
if (passRate >= 75) return 'B';
if (passRate >= 60) return 'C';
if (passRate >= 40) return 'D';
return 'F';
}
/** Config audit quality categories */
export const QUALITY_CATEGORIES = Object.freeze({
STRUCTURE: 'Structure & Format',
CONTENT: 'Content Quality',
HIERARCHY: 'Hierarchy & Scope',
SECURITY: 'Security',
FEATURES: 'Feature Utilization',
COHERENCE: 'Cross-file Coherence',
});

View file

@ -0,0 +1,74 @@
/**
* String utilities for config-audit scanners.
* Zero external dependencies.
*/
/**
* Count lines in a string.
* @param {string} s
* @returns {number}
*/
export function lineCount(s) {
if (!s) return 0;
return s.split('\n').length;
}
/**
* Truncate a string to maxLen chars with ellipsis.
* @param {string} s
* @param {number} [maxLen=100]
* @returns {string}
*/
export function truncate(s, maxLen = 100) {
if (!s || s.length <= maxLen) return s || '';
return s.slice(0, maxLen - 3) + '...';
}
/**
* Check if two strings have >threshold% content similarity (word overlap).
* @param {string} a
* @param {string} b
* @param {number} [threshold=0.8]
* @returns {boolean}
*/
export function isSimilar(a, b, threshold = 0.8) {
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
if (wordsA.size === 0 || wordsB.size === 0) return false;
let overlap = 0;
for (const w of wordsA) {
if (wordsB.has(w)) overlap++;
}
const similarity = overlap / Math.min(wordsA.size, wordsB.size);
return similarity >= threshold;
}
/**
* Extract all key-like patterns from a settings.json or similar config.
* @param {object} obj
* @param {string} [prefix='']
* @returns {string[]}
*/
export function extractKeys(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.push(fullKey);
if (value && typeof value === 'object' && !Array.isArray(value)) {
keys.push(...extractKeys(value, fullKey));
}
}
return keys;
}
/**
* Normalize a file path for comparison (resolve ~, handle trailing slashes).
* @param {string} p
* @returns {string}
*/
export function normalizePath(p) {
const home = process.env.HOME || process.env.USERPROFILE || '';
let normalized = p.replace(/^~/, home);
normalized = normalized.replace(/[/\\]+$/, '');
return normalized;
}

View file

@ -0,0 +1,154 @@
/**
* Suppression engine for config-audit.
* Lets users suppress known false positives via .config-audit-ignore files.
* Supports exact IDs (CA-CML-001) and glob patterns (CA-SET-*).
* Zero external dependencies.
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
/**
* Load suppressions from .config-audit-ignore files.
* Searches targetPath first, then ~/.claude/config-audit/.
* Project-level file takes precedence (loaded first).
* @param {string} targetPath - Project root to search
* @returns {Promise<{ suppressions: Array<{ pattern: string, comment: string }>, source: string }>}
*/
export async function loadSuppressions(targetPath) {
const sources = [
{ path: join(targetPath, '.config-audit-ignore'), label: 'project' },
{ path: join(homedir(), '.config-audit', '.config-audit-ignore'), label: 'global' },
];
for (const src of sources) {
try {
const content = await readFile(src.path, 'utf-8');
const suppressions = parseIgnoreFile(content);
return { suppressions, source: src.label };
} catch {
// File doesn't exist — try next
}
}
return { suppressions: [], source: 'none' };
}
/**
* Parse a .config-audit-ignore file into suppression entries.
* @param {string} content - File content
* @returns {Array<{ pattern: string, comment: string }>}
*/
export function parseIgnoreFile(content) {
const suppressions = [];
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
// Skip empty lines and comment-only lines
if (!line || line.startsWith('#')) continue;
// Split on first # for inline comment
const hashIdx = line.indexOf('#');
let pattern, comment;
if (hashIdx > 0) {
pattern = line.slice(0, hashIdx).trim();
comment = line.slice(hashIdx + 1).trim();
} else {
pattern = line;
comment = '';
}
// Validate pattern looks like a finding ID or glob
if (/^CA-[A-Z]{2,4}[-*\d]+/.test(pattern) || /^CA-[A-Z]{2,4}-\*$/.test(pattern)) {
suppressions.push({ pattern, comment });
}
}
return suppressions;
}
/**
* Apply suppressions to a findings array.
* @param {object[]} findings - Array of finding objects with .id
* @param {Array<{ pattern: string, comment: string }>} suppressions
* @returns {{ active: object[], suppressed: object[] }}
*/
export function applySuppressions(findings, suppressions) {
if (!suppressions || suppressions.length === 0) {
return { active: [...findings], suppressed: [] };
}
const active = [];
const suppressed = [];
for (const f of findings) {
if (isMatchedByAny(f.id, suppressions)) {
suppressed.push(f);
} else {
active.push(f);
}
}
return { active, suppressed };
}
/**
* Check if a finding ID matches any suppression pattern.
* @param {string} id - Finding ID (e.g. CA-CML-001)
* @param {Array<{ pattern: string }>} suppressions
* @returns {boolean}
*/
function isMatchedByAny(id, suppressions) {
for (const s of suppressions) {
if (matchPattern(id, s.pattern)) return true;
}
return false;
}
/**
* Match a finding ID against a suppression pattern.
* Supports exact match and glob-style CA-XXX-* patterns.
* @param {string} id - e.g. "CA-CML-001"
* @param {string} pattern - e.g. "CA-CML-001" or "CA-CML-*"
* @returns {boolean}
*/
function matchPattern(id, pattern) {
// Exact match
if (id === pattern) return true;
// Glob: CA-XXX-* matches any CA-XXX-NNN
if (pattern.endsWith('-*')) {
const prefix = pattern.slice(0, -1); // "CA-XXX-"
return id.startsWith(prefix);
}
return false;
}
/**
* Format a human-readable suppression summary line.
* @param {object[]} suppressed - Array of suppressed findings
* @returns {string}
*/
export function formatSuppressionSummary(suppressed) {
if (!suppressed || suppressed.length === 0) {
return '0 findings suppressed';
}
// Group by scanner prefix pattern
const groups = new Map();
for (const f of suppressed) {
// Extract prefix: CA-CML-001 → CA-CML
const prefix = f.id.replace(/-\d+$/, '');
groups.set(prefix, (groups.get(prefix) || 0) + 1);
}
const parts = [];
for (const [prefix, count] of groups) {
parts.push(`${count} \u00d7 ${prefix}-*`);
}
return `${suppressed.length} finding(s) suppressed (${parts.join(', ')})`;
}

View file

@ -0,0 +1,182 @@
/**
* Regex-based YAML frontmatter parser for Claude Code .md files.
* Handles YAML frontmatter (--- delimited) and basic YAML parsing.
* Zero external dependencies.
*/
/**
* Parse YAML frontmatter from markdown content.
* @param {string} content
* @returns {{ frontmatter: object | null, body: string, bodyStartLine: number }}
*/
export function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---(?:\r?\n|$)/);
if (!match) {
return { frontmatter: null, body: content, bodyStartLine: 1 };
}
const raw = match[1];
const bodyStartLine = raw.split('\n').length + 3; // 2 for --- lines + 1-based
const body = content.slice(match[0].length);
const frontmatter = parseSimpleYaml(raw);
return { frontmatter, body, bodyStartLine };
}
/**
* Parse simple YAML key-value pairs (no nesting beyond arrays).
* @param {string} yaml
* @returns {object}
*/
export function parseSimpleYaml(yaml) {
const result = {};
const lines = yaml.split('\n');
let currentKey = null;
let multiLineValue = '';
let inMultiLine = false;
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith('#') || line.trim() === '') {
if (inMultiLine) multiLineValue += '\n';
continue;
}
// Key-value pair
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
if (kvMatch && !inMultiLine) {
if (currentKey && multiLineValue) {
result[normalizeKey(currentKey)] = multiLineValue.trim();
}
currentKey = kvMatch[1];
const value = kvMatch[2].trim();
if (value === '|' || value === '>') {
inMultiLine = true;
multiLineValue = '';
continue;
}
result[normalizeKey(currentKey)] = parseValue(value);
currentKey = null;
continue;
}
// Multi-line continuation
if (inMultiLine) {
if (line.match(/^\s+/)) {
multiLineValue += (multiLineValue ? '\n' : '') + line.trim();
} else {
result[normalizeKey(currentKey)] = multiLineValue.trim();
inMultiLine = false;
multiLineValue = '';
// Re-process this line as a new key
const reMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
if (reMatch) {
currentKey = reMatch[1];
result[normalizeKey(currentKey)] = parseValue(reMatch[2].trim());
currentKey = null;
}
}
}
}
// Flush remaining multi-line
if (inMultiLine && currentKey) {
result[normalizeKey(currentKey)] = multiLineValue.trim();
}
// Normalize arrays for known list fields
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
if (typeof result[field] === 'string') {
result[field] = result[field].split(',').map(s => s.trim()).filter(Boolean);
}
}
return result;
}
/**
* Parse a YAML value string.
*/
function parseValue(str) {
if (str === '' || str === '~' || str === 'null') return null;
if (str === 'true') return true;
if (str === 'false') return false;
if (/^\d+$/.test(str)) return parseInt(str, 10);
if (/^\d+\.\d+$/.test(str)) return parseFloat(str);
// Inline array: [a, b, c]
if (str.startsWith('[') && str.endsWith(']')) {
return str.slice(1, -1).split(',').map(s => {
const v = s.trim();
return v.replace(/^["']|["']$/g, '');
}).filter(Boolean);
}
// Quoted string
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
return str.slice(1, -1);
}
return str;
}
/**
* Normalize key: hyphens to underscores.
*/
function normalizeKey(key) {
return key.replace(/-/g, '_');
}
/**
* Parse a JSON file content. Returns null on error.
* @param {string} content
* @returns {object | null}
*/
export function parseJson(content) {
try {
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Find @import references in CLAUDE.md content.
* @param {string} content
* @returns {{ path: string, line: number }[]}
*/
export function findImports(content) {
const imports = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^@(.+)$/);
if (match) {
imports.push({ path: match[1].trim(), line: i + 1 });
}
}
return imports;
}
/**
* Extract markdown sections (## headings) from content.
* @param {string} content
* @returns {{ heading: string, level: number, line: number }[]}
*/
export function extractSections(content) {
const sections = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(#{1,6})\s+(.+)/);
if (match) {
sections.push({
heading: match[2].trim(),
level: match[1].length,
line: i + 1,
});
}
}
return sections;
}

View file

@ -0,0 +1,153 @@
/**
* MCP Scanner MCP Configuration Validator
* Validates .mcp.json files: server types, trust levels, env vars, unknown fields.
* Finding IDs: CA-MCP-NNN
*/
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';
const SCANNER = 'MCP';
const VALID_SERVER_TYPES = new Set(['stdio', 'http', 'sse']);
const VALID_TRUST_LEVELS = new Set(['workspace', 'trusted', 'untrusted']);
const VALID_SERVER_FIELDS = new Set([
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust',
]);
const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
/**
* Scan all .mcp.json files discovered.
* @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 mcpFiles = discovery.files.filter(f => f.type === 'mcp-json');
const findings = [];
let filesScanned = 0;
if (mcpFiles.length === 0) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
for (const file of mcpFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const parsed = parseJson(content);
if (!parsed) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid JSON in MCP config',
description: `${file.relPath}: Failed to parse as JSON.`,
file: file.absPath,
recommendation: 'Fix JSON syntax errors. Use a JSON validator to check the file.',
}));
continue;
}
const servers = parsed.mcpServers || parsed;
if (typeof servers !== 'object' || Array.isArray(servers)) continue;
for (const [name, config] of Object.entries(servers)) {
if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
// Check server type
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Unknown MCP server type',
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
file: file.absPath,
evidence: `type: "${config.type}"`,
recommendation: `Use one of: stdio, http, sse. Got "${config.type}".`,
}));
}
// SSE → HTTP recommendation
if (config.type === 'sse') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'SSE server type — consider HTTP',
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
file: file.absPath,
evidence: `type: "sse"`,
recommendation: 'Migrate from "sse" to "http" type for better compatibility.',
}));
}
// Check trust level
if (!config.trust) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Missing trust level',
description: `${file.relPath}: Server "${name}" has no trust level configured.`,
file: file.absPath,
recommendation: 'Add "trust": "workspace"|"trusted"|"untrusted" to explicitly set the trust level.',
}));
} else if (!VALID_TRUST_LEVELS.has(config.trust)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid trust level',
description: `${file.relPath}: Server "${name}" has invalid trust level "${config.trust}".`,
file: file.absPath,
evidence: `trust: "${config.trust}"`,
recommendation: 'Use one of: workspace, trusted, untrusted.',
}));
}
// Check for env var references in args without env block
if (Array.isArray(config.args)) {
for (const arg of config.args) {
if (typeof arg !== 'string') continue;
let match;
ENV_VAR_PATTERN.lastIndex = 0;
while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) {
const varName = match[1];
const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env;
if (!hasEnvBlock) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Unreferenced env var in args',
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
file: file.absPath,
evidence: truncate(arg, 80),
recommendation: `Add an "env" block with "${varName}" or remove the variable reference.`,
}));
}
}
}
}
// Check for unknown fields
for (const key of Object.keys(config)) {
if (!VALID_SERVER_FIELDS.has(key)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Unknown MCP server field',
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,
file: file.absPath,
evidence: `${key}: ${truncate(JSON.stringify(config[key]), 60)}`,
recommendation: `Remove or correct "${key}". Valid fields: ${[...VALID_SERVER_FIELDS].join(', ')}.`,
}));
}
}
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

View file

@ -0,0 +1,455 @@
#!/usr/bin/env node
/**
* PLH Scanner Plugin Health
* Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
* Finding IDs: CA-PLH-NNN
* NOT included in scan-orchestrator runs independently on plugin directories.
* Zero external dependencies.
*/
import { readdir, stat, readFile } from 'node:fs/promises';
import { join, basename, resolve } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
const SCANNER = 'PLH';
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
const REQUIRED_COMMAND_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'allowed_tools', display: 'allowed-tools' },
];
const REQUIRED_AGENT_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'tools', display: 'tools' },
];
/**
* Discover plugins under a path.
* Looks for .claude-plugin/plugin.json pattern.
* @param {string} targetPath
* @returns {Promise<string[]>} Array of plugin root directories
*/
export async function discoverPlugins(targetPath) {
const plugins = [];
// Check if targetPath itself is a plugin
if (await isPlugin(targetPath)) {
plugins.push(targetPath);
return plugins;
}
// Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
try {
const entries = await readdir(targetPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const subDir = join(targetPath, entry.name);
if (await isPlugin(subDir)) {
plugins.push(subDir);
continue;
}
// Also check one level deeper (plugins/<name>/ layout)
try {
const subEntries = await readdir(subDir, { withFileTypes: true });
for (const subEntry of subEntries) {
if (!subEntry.isDirectory()) continue;
const deepDir = join(subDir, subEntry.name);
if (await isPlugin(deepDir)) {
plugins.push(deepDir);
}
}
} catch { /* skip */ }
}
} catch { /* skip */ }
return plugins;
}
/**
* Check if a directory is a Claude Code plugin.
* @param {string} dir
* @returns {Promise<boolean>}
*/
async function isPlugin(dir) {
try {
await stat(join(dir, '.claude-plugin', 'plugin.json'));
return true;
} catch {
return false;
}
}
/**
* Scan a single plugin for health issues.
* @param {string} pluginDir - Plugin root directory
* @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
*/
async function scanSinglePlugin(pluginDir) {
const findings = [];
const pluginName = basename(pluginDir);
let commandCount = 0;
let agentCount = 0;
// 1. Validate plugin.json
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
try {
const content = await readFile(pluginJsonPath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(content);
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid plugin.json',
description: `plugin.json is not valid JSON in ${pluginName}`,
file: pluginJsonPath,
}));
parsed = null;
}
if (parsed) {
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
if (!parsed[field]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: `Missing required field in plugin.json: ${field}`,
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
file: pluginJsonPath,
recommendation: `Add "${field}" to plugin.json`,
}));
}
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Missing plugin.json',
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
file: pluginDir,
recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
}));
}
// 2. Validate CLAUDE.md
const claudeMdPath = join(pluginDir, 'CLAUDE.md');
try {
const content = await readFile(claudeMdPath, 'utf-8');
const lower = content.toLowerCase();
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
// Look for markdown table header or section header
const hasSection = lower.includes(`## ${section}`) ||
lower.includes(`| ${section}`) ||
lower.includes(`|${section}`);
if (!hasSection) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `CLAUDE.md missing ${section} section`,
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
file: claudeMdPath,
recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
}));
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Missing CLAUDE.md',
description: `Plugin "${pluginName}" has no CLAUDE.md`,
file: pluginDir,
recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
}));
}
// 3. Validate commands frontmatter
const commandsDir = join(pluginDir, 'commands');
try {
const entries = await readdir(commandsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
commandCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Command missing frontmatter',
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model',
}));
continue;
}
for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Command missing frontmatter field: ${display}`,
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
}
} catch { /* no commands dir */ }
// 4. Validate agents frontmatter
const agentsDir = join(pluginDir, 'agents');
try {
const entries = await readdir(agentsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
agentCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(agentsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Agent missing frontmatter',
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model, tools',
}));
continue;
}
for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Agent missing frontmatter field: ${display}`,
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
}
} catch { /* no agents dir */ }
// 5. Validate hooks.json (if exists)
const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
try {
const content = await readFile(hooksJsonPath, 'utf-8');
try {
const parsed = JSON.parse(content);
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json structure',
description: `hooks.json in "${pluginName}" missing "hooks" object`,
file: hooksJsonPath,
recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
}));
} else if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'hooks.json uses array instead of object',
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
file: hooksJsonPath,
recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
}));
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json',
description: `hooks.json is not valid JSON in "${pluginName}"`,
file: hooksJsonPath,
}));
}
} catch { /* no hooks.json — fine */ }
// 6. Check for unknown files in .claude-plugin/
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
const known = new Set(['plugin.json']);
for (const entry of entries) {
if (!known.has(entry)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Unknown file in .claude-plugin/',
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
file: join(pluginMetaDir, entry),
recommendation: 'Only plugin.json should be in .claude-plugin/',
}));
}
}
} catch { /* skip */ }
return { name: pluginName, findings, commandCount, agentCount };
}
/**
* Scan one or more plugins and return aggregated results.
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<object>} Scanner result
*/
export async function scan(targetPath) {
const start = Date.now();
resetCounter();
const pluginDirs = await discoverPlugins(resolve(targetPath));
if (pluginDirs.length === 0) {
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'No plugins found',
description: `No Claude Code plugins found under ${targetPath}`,
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
}),
], 0, Date.now() - start);
}
const allFindings = [];
const pluginResults = [];
for (const dir of pluginDirs) {
const result = await scanSinglePlugin(dir);
pluginResults.push(result);
allFindings.push(...result.findings);
}
// Cross-plugin checks: command name conflicts
const commandNames = new Map(); // name → plugin
for (let idx = 0; idx < pluginResults.length; idx++) {
const pr = pluginResults[idx];
const commandsDir = join(pluginDirs[idx], 'commands');
try {
const entries = await readdir(commandsDir);
for (const file of entries.filter(f => f.endsWith('.md'))) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && frontmatter.name) {
const cmdName = frontmatter.name;
if (commandNames.has(cmdName)) {
allFindings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Cross-plugin command name conflict',
description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`,
file: filePath,
recommendation: `Rename one of the conflicting commands to avoid ambiguity`,
}));
} else {
commandNames.set(cmdName, pr.name);
}
}
}
} catch { /* no commands dir */ }
}
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
}
/**
* Format a plugin health report for terminal output.
* @param {object} scanResult - Scanner result from scan()
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
* @returns {string}
*/
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Plugin Health Report');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push('');
for (const p of pluginResults) {
const issueCount = p.findings.length;
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
lines.push('');
if (crossPluginFindings.length > 0) {
lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
for (const f of crossPluginFindings) {
lines.push(` - [${f.severity}] ${f.title}`);
}
} else {
lines.push(' Cross-plugin issues (0):');
lines.push(' (none)');
}
lines.push('');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let jsonMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') {
jsonMode = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
process.stderr.write(`Plugin Health Scanner v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
const result = await scan(targetPath);
if (jsonMode) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
// Brief summary
const count = result.findings.length;
process.stderr.write(`Findings: ${count}\n`);
for (const f of result.findings) {
process.stderr.write(` [${f.severity}] ${f.title}\n`);
}
}
}
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Config-Audit Posture Assessment CLI
* Runs all scanners + scoring in a single Node.js process.
* Usage: node posture.mjs <target-path> [--json] [--global] [--output-file path]
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { runAllScanners } from './scan-orchestrator.mjs';
import {
calculateUtilization,
determineMaturityLevel,
determineSegment,
scoreByArea,
topActions,
generateScorecard,
generateHealthScorecard,
} from './lib/scoring.mjs';
/**
* Run posture assessment and return structured result.
* @param {string} targetPath
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false]
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
* @returns {Promise<object>}
*/
export async function runPosture(targetPath, opts = {}) {
const envelope = await runAllScanners(targetPath, opts);
// Extract GAP scanner results
const gapScanner = envelope.scanners.find(s => s.scanner === 'GAP');
const gapFindings = gapScanner ? gapScanner.findings : [];
// Calculate scores
const utilization = calculateUtilization(gapFindings);
const maturity = determineMaturityLevel(gapFindings, { files: [] });
const segment = determineSegment(utilization.score);
const areaScores = scoreByArea(envelope.scanners);
const actions = topActions(gapFindings);
return {
utilization,
maturity,
segment,
areas: areaScores.areas,
overallGrade: areaScores.overallGrade,
topActions: actions,
opportunityCount: gapFindings.length,
scannerEnvelope: envelope,
};
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
let includeGlobal = false;
let fullMachine = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output-file' && args[i + 1]) {
outputFile = args[++i];
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (args[i] === '--full-machine') {
fullMachine = true;
} else if (args[i] === '--include-fixtures') {
// handled below
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
const filterFixtures = !args.includes('--include-fixtures');
const result = await runPosture(targetPath, { includeGlobal, fullMachine, filterFixtures });
if (jsonMode) {
const json = JSON.stringify(result, null, 2);
process.stdout.write(json + '\n');
} else {
// Terminal scorecard (v3 health format)
const scorecard = generateHealthScorecard(
{ areas: result.areas, overallGrade: result.overallGrade },
result.opportunityCount,
);
process.stderr.write('\n' + scorecard + '\n');
}
if (outputFile) {
const json = JSON.stringify(result, null, 2);
await writeFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
// Only run CLI if invoked directly
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(1);
});
}

View file

@ -0,0 +1,166 @@
/**
* Config-Audit Rollback Engine
* Restores configuration from backup with checksum verification.
* Zero external dependencies.
*/
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
/**
* List all available backups.
* @returns {Promise<{ backups: object[] }>}
*/
export async function listBackups() {
const backupRoot = getBackupDir();
const backups = [];
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
try {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
});
} catch {
// Skip backups without valid manifest
continue;
}
}
// Sort newest first
backups.sort((a, b) => b.id.localeCompare(a.id));
return { backups };
}
/**
* Restore files from a backup.
* @param {string} backupId
* @param {object} [opts]
* @param {boolean} [opts.dryRun=false]
* @param {boolean} [opts.verify=true]
* @returns {Promise<{ restored: object[], failed: object[] }>}
*/
export async function restoreBackup(backupId, opts = {}) {
const verify = opts.verify !== false;
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
const manifest = parseManifest(manifestContent);
const restored = [];
const failed = [];
for (const fileEntry of manifest.files) {
const backupFilePath = join(backupPath, fileEntry.backupPath);
if (opts.dryRun) {
restored.push({
originalPath: fileEntry.originalPath,
status: 'dry-run',
});
continue;
}
try {
// Read backup file
const content = await readFile(backupFilePath);
// Verify checksum before restoring
if (verify) {
const hash = checksum(content);
if (hash !== fileEntry.checksum) {
failed.push({
originalPath: fileEntry.originalPath,
status: 'checksum-mismatch',
error: `Expected ${fileEntry.checksum}, got ${hash}`,
});
continue;
}
}
// Write to original path
await writeFile(fileEntry.originalPath, content);
// Verify after write
if (verify) {
const written = await readFile(fileEntry.originalPath);
const writtenHash = checksum(written);
if (writtenHash !== fileEntry.checksum) {
failed.push({
originalPath: fileEntry.originalPath,
status: 'checksum-mismatch',
error: 'Checksum mismatch after write',
});
continue;
}
}
restored.push({
originalPath: fileEntry.originalPath,
status: 'restored',
});
} catch (err) {
failed.push({
originalPath: fileEntry.originalPath,
status: 'failed',
error: err.message,
});
}
}
return { restored, failed };
}
/**
* Delete a backup directory.
* @param {string} backupId
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
try {
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { recursive: true, force: true });
return { deleted: true };
} catch (err) {
return { deleted: false, error: err.message };
}
}

View file

@ -0,0 +1,217 @@
/**
* RUL Scanner Rules Validator
* Validates .claude/rules/ files: glob matching against real files, orphan detection, frontmatter.
* Finding IDs: CA-RUL-NNN
*/
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
import { readdir, stat } from 'node:fs/promises';
import { join, resolve, relative } from 'node:path';
const SCANNER = 'RUL';
/**
* Scan .claude/rules/ directories for issues.
* @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 ruleFiles = discovery.files.filter(f => f.type === 'rule');
const findings = [];
let filesScanned = 0;
if (ruleFiles.length === 0) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
// Collect all real files in the project for glob matching
const projectFiles = await collectProjectFiles(targetPath);
for (const file of ruleFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const { frontmatter, body, bodyStartLine } = parseFrontmatter(content);
const lines = lineCount(content);
// --- Frontmatter checks ---
if (!frontmatter) {
// Rules without frontmatter are "always on" — not necessarily wrong, just note it
if (lines > 5) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Rule has no frontmatter (always active)',
description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`,
file: file.absPath,
recommendation: 'Add frontmatter with paths: to limit when this rule applies.',
}));
}
} else {
// Check for paths/globs frontmatter
const paths = frontmatter.paths || frontmatter.globs;
if (frontmatter.globs && !frontmatter.paths) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Rule uses deprecated "globs" field',
description: `${file.relPath} uses "globs:" which is legacy. Use "paths:" instead.`,
file: file.absPath,
evidence: `globs: ${JSON.stringify(frontmatter.globs)}`,
recommendation: 'Rename "globs:" to "paths:" in frontmatter.',
autoFixable: true,
}));
}
if (paths) {
const patterns = Array.isArray(paths) ? paths : [paths];
for (const pattern of patterns) {
if (typeof pattern !== 'string') continue;
// Check if pattern matches any real files
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
if (matchCount === 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Rule path pattern matches no files',
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
file: file.absPath,
evidence: `paths: "${pattern}"`,
recommendation: 'Check the glob pattern. Common issues: wrong directory name, missing **, incorrect extension.',
autoFixable: false,
}));
}
}
}
}
// --- Content quality checks ---
if (lines < 2) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Rule file is nearly empty',
description: `${file.relPath} has only ${lines} line(s).`,
file: file.absPath,
recommendation: 'Add meaningful content or remove the file.',
autoFixable: false,
}));
}
// Check for overly broad rules (huge files without path scoping)
if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Large unscoped rule file',
description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`,
file: file.absPath,
evidence: `${lines} lines, no paths: frontmatter`,
recommendation: 'Add paths: frontmatter to scope this rule, or split into smaller path-specific rules.',
autoFixable: false,
}));
}
// Check file extension
if (!file.absPath.endsWith('.md')) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Rule file is not .md',
description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`,
file: file.absPath,
recommendation: 'Rename to .md extension.',
autoFixable: true,
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}
/**
* Collect project file paths for glob matching (limited depth).
* @param {string} targetPath
* @returns {Promise<string[]>}
*/
async function collectProjectFiles(targetPath, depth = 0) {
if (depth > 4) return [];
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'vendor']);
const files = [];
let entries;
try {
entries = await readdir(targetPath, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const fullPath = join(targetPath, entry.name);
if (entry.isFile()) {
files.push(fullPath);
} else if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith('.')) {
const subFiles = await collectProjectFiles(fullPath, depth + 1);
files.push(...subFiles);
if (files.length > 5000) break; // Safety limit
}
}
return files;
}
/**
* Count how many files match a simplified glob pattern.
* Supports: *, **, specific extensions.
* @param {string} pattern
* @param {string[]} files
* @param {string} basePath
* @returns {number}
*/
function countGlobMatches(pattern, files, basePath) {
try {
const regex = globToRegex(pattern);
let count = 0;
for (const file of files) {
const rel = relative(basePath, file);
if (regex.test(rel)) count++;
}
return count;
} catch {
return -1; // Pattern parsing error — don't report as orphan
}
}
/**
* Convert a simple glob pattern to a regex.
* Handles ** matching zero or more path segments.
* @param {string} pattern
* @returns {RegExp}
*/
function globToRegex(pattern) {
let regex = pattern
.replace(/\./g, '\\.')
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
.replace(/\*\*/g, '{{GLOBSTAR}}')
.replace(/\*/g, '[^/]*')
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
.replace(/\?/g, '[^/]');
// Handle leading patterns
if (!regex.startsWith('.*') && !regex.startsWith('/')) {
regex = '(?:^|/)' + regex;
}
return new RegExp(regex);
}

View file

@ -0,0 +1,248 @@
#!/usr/bin/env node
/**
* Config-Audit Scan Orchestrator
* Runs all registered scanners sequentially, collects findings, outputs JSON envelope.
* Usage: node scan-orchestrator.mjs <target-path> [--output-file path] [--save-baseline] [--baseline path]
* Zero external dependencies.
*/
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { resetCounter } from './lib/output.mjs';
import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
// Scanner registry — import order determines execution order
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
import { scan as scanSettings } from './settings-validator.mjs';
import { scan as scanHooks } from './hook-validator.mjs';
import { scan as scanRules } from './rules-validator.mjs';
import { scan as scanMcp } from './mcp-config-validator.mjs';
import { scan as scanImports } from './import-resolver.mjs';
import { scan as scanConflicts } from './conflict-detector.mjs';
import { scan as scanGap } from './feature-gap-scanner.mjs';
// Directory names that identify test fixture / example directories
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
/**
* Check if a finding originates from a test fixture or example directory
* relative to the scan target. Only filters when the finding's path extends
* beyond the target into a fixture subdirectory if the target itself is
* a fixture directory, findings are NOT filtered.
* @param {object} f - Finding object
* @param {string} targetPath - Resolved scan target path
* @returns {boolean}
*/
function isFixturePath(f, targetPath) {
const p = f.file || f.path || f.location || '';
if (!p || !p.startsWith(targetPath)) return false;
// Get the path relative to target, then check if it passes through a fixture dir
const rel = p.slice(targetPath.length);
return FIXTURE_DIR_NAMES.some(dir => rel.includes(sep + dir + sep));
}
const SCANNERS = [
{ name: 'CML', fn: scanClaudeMd, label: 'CLAUDE.md Linter' },
{ name: 'SET', fn: scanSettings, label: 'Settings Validator' },
{ name: 'HKV', fn: scanHooks, label: 'Hook Validator' },
{ name: 'RUL', fn: scanRules, label: 'Rules Validator' },
{ name: 'MCP', fn: scanMcp, label: 'MCP Config Validator' },
{ name: 'IMP', fn: scanImports, label: 'Import Resolver' },
{ name: 'CNF', fn: scanConflicts, label: 'Conflict Detector' },
{ name: 'GAP', fn: scanGap, label: 'Feature Gap Scanner' },
];
/**
* Run all scanners against target path.
* @param {string} targetPath
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false]
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
* @returns {Promise<object>} Full envelope with all results
*/
// Exported for testing
export { isFixturePath, FIXTURE_DIR_NAMES };
export async function runAllScanners(targetPath, opts = {}) {
const start = Date.now();
const resolvedPath = resolve(targetPath);
// Shared file discovery — scanners reuse this
let discovery;
if (opts.fullMachine) {
const roots = await discoverFullMachinePaths();
discovery = await discoverConfigFilesMulti(roots);
} else {
discovery = await discoverConfigFiles(resolvedPath, {
includeGlobal: opts.includeGlobal || false,
});
}
const results = [];
for (const scanner of SCANNERS) {
resetCounter();
const scanStart = Date.now();
try {
const result = await scanner.fn(resolvedPath, discovery);
results.push(result);
const count = result.findings.length;
process.stderr.write(` [${scanner.name}] ${scanner.label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
} catch (err) {
results.push({
scanner: scanner.name,
status: 'error',
files_scanned: 0,
duration_ms: Date.now() - scanStart,
findings: [],
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
error: err.message,
});
process.stderr.write(` [${scanner.name}] ${scanner.label}: ERROR — ${err.message}\n`);
}
}
// Filter findings from test fixtures / examples (unless disabled)
const shouldFilterFixtures = opts.filterFixtures !== false;
let fixtureFindings = [];
if (shouldFilterFixtures) {
for (const result of results) {
const active = [];
const fixture = [];
for (const f of result.findings) {
if (isFixturePath(f, resolvedPath)) {
fixture.push(f);
} else {
active.push(f);
}
}
if (fixture.length > 0) {
fixtureFindings.push(...fixture);
result.findings = active;
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of active) {
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
}
}
}
if (fixtureFindings.length > 0) {
process.stderr.write(` ${fixtureFindings.length} finding(s) from test fixtures excluded\n`);
}
}
// Apply suppressions (unless disabled)
const shouldSuppress = opts.suppress !== false;
let suppressedFindings = [];
if (shouldSuppress) {
const { suppressions } = await loadSuppressions(resolvedPath);
if (suppressions.length > 0) {
for (const result of results) {
const { active, suppressed } = applySuppressions(result.findings, suppressions);
suppressedFindings.push(...suppressed);
result.findings = active;
// Recalculate counts
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of active) {
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
}
}
if (suppressedFindings.length > 0) {
process.stderr.write(` ${formatSuppressionSummary(suppressedFindings)}\n`);
}
}
}
const totalMs = Date.now() - start;
const env = envelope(resolvedPath, results, totalMs);
if (fixtureFindings.length > 0) {
env.fixture_findings = fixtureFindings;
}
if (suppressedFindings.length > 0) {
env.suppressed_findings = suppressedFindings;
}
return env;
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let outputFile = null;
let saveBaseline = false;
let baselinePath = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output-file' && args[i + 1]) {
outputFile = args[++i];
} else if (args[i] === '--save-baseline') {
saveBaseline = true;
} else if (args[i] === '--baseline' && args[i + 1]) {
baselinePath = args[++i];
} else if (args[i] === '--global') {
// handled below
} else if (args[i] === '--full-machine') {
// handled below
} else if (args[i] === '--no-suppress') {
// handled below
} else if (args[i] === '--include-fixtures') {
// handled below
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
const includeGlobal = args.includes('--global');
const fullMachine = args.includes('--full-machine');
const suppress = !args.includes('--no-suppress');
const filterFixtures = !args.includes('--include-fixtures');
process.stderr.write(`Config-Audit Scanner v2.2.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
process.stderr.write(`Scope: ${fullMachine ? 'full-machine' : includeGlobal ? 'global' : 'project'}\n`);
process.stderr.write(`Fixtures: ${filterFixtures ? 'excluded' : 'included'}\n\n`);
const result = await runAllScanners(targetPath, { includeGlobal, fullMachine, suppress, filterFixtures });
const json = JSON.stringify(result, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
} else {
process.stdout.write(json + '\n');
}
if (saveBaseline) {
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
await writeFile(bPath, json, 'utf-8');
process.stderr.write(`Baseline saved to ${bPath}\n`);
}
// Summary
const agg = result.aggregate;
process.stderr.write(`\n--- Summary ---\n`);
process.stderr.write(`Findings: ${agg.total_findings} (C:${agg.counts.critical} H:${agg.counts.high} M:${agg.counts.medium} L:${agg.counts.low} I:${agg.counts.info})\n`);
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
process.stderr.write(`Verdict: ${agg.verdict}\n`);
// Exit code
if (agg.verdict === 'FAIL') process.exit(2);
if (agg.verdict === 'WARNING') process.exit(1);
process.exit(0);
}
// Only run CLI if invoked directly
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Config-Audit Self-Audit
* Runs the plugin's own scanners on its own configuration.
* CLI: node self-audit.mjs [--json] [--fix]
* Exit codes: 0=PASS (no critical/high), 1=WARN (high findings), 2=FAIL (critical findings)
* Zero external dependencies.
*/
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runAllScanners } from './scan-orchestrator.mjs';
import { scan as scanPluginHealth } from './plugin-health-scanner.mjs';
import { scoreByArea } from './lib/scoring.mjs';
import { gradeFromPassRate } from './lib/severity.mjs';
import { loadSuppressions, applySuppressions } from './lib/suppression.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '..');
/**
* Run self-audit on this plugin.
* @param {object} [opts]
* @param {boolean} [opts.fix=false] - Run fix-engine on auto-fixable findings
* @returns {Promise<object>} Combined result
*/
export async function runSelfAudit(opts = {}) {
const pluginDir = PLUGIN_ROOT;
// 1. Run all config scanners on plugin root
// Fixture filtering is handled automatically by runAllScanners (filterFixtures defaults to true)
const configEnvelope = await runAllScanners(pluginDir);
// 2. Run plugin health scanner + apply suppressions
const pluginHealthResult = await scanPluginHealth(pluginDir);
const { suppressions } = await loadSuppressions(pluginDir);
if (suppressions.length > 0) {
const { active, suppressed } = applySuppressions(pluginHealthResult.findings, suppressions);
pluginHealthResult.findings = active;
pluginHealthResult.suppressedFindings = suppressed;
}
// 3. Score config quality
const areaScores = scoreByArea(configEnvelope.scanners);
const avgScore = areaScores.areas.length > 0
? Math.round(areaScores.areas.reduce((s, a) => s + a.score, 0) / areaScores.areas.length)
: 0;
const configGrade = gradeFromPassRate(avgScore);
// 4. Score plugin health
const pluginIssueCount = pluginHealthResult.findings.length;
const pluginScore = Math.max(0, 100 - pluginIssueCount * 10);
const pluginGrade = gradeFromPassRate(pluginScore);
// 5. Determine overall result
const allFindings = [
...configEnvelope.scanners.flatMap(s => s.findings),
...pluginHealthResult.findings,
];
const hasCritical = allFindings.some(f => f.severity === 'critical');
const hasHigh = allFindings.some(f => f.severity === 'high');
let exitCode = 0;
let verdict = 'PASS';
if (hasCritical) { exitCode = 2; verdict = 'FAIL'; }
else if (hasHigh) { exitCode = 1; verdict = 'WARN'; }
// 6. Optionally fix
let fixResult = null;
if (opts.fix && allFindings.some(f => f.autoFixable)) {
try {
const { planFixes, applyFixes } = await import('./fix-engine.mjs');
const plan = planFixes(configEnvelope);
if (plan.length > 0) {
fixResult = await applyFixes(plan);
}
} catch {
// Fix engine unavailable or failed — non-fatal
}
}
return {
pluginDir,
configGrade,
configScore: avgScore,
pluginGrade,
pluginScore,
configEnvelope,
pluginHealthResult,
allFindings,
exitCode,
verdict,
fixResult,
};
}
/**
* Format self-audit result for terminal display.
* @param {object} result - From runSelfAudit()
* @returns {string}
*/
export function formatSelfAudit(result) {
const lines = [];
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
lines.push(' Config-Audit Self-Audit');
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
lines.push('');
lines.push(` Plugin health: ${result.pluginGrade} (${result.pluginScore})`);
lines.push(` Config quality: ${result.configGrade} (${result.configScore})`);
lines.push('');
// Issues summary
const nonInfo = result.allFindings.filter(f => f.severity !== 'info');
if (nonInfo.length > 0) {
lines.push(` Issues (${nonInfo.length}):`);
for (const f of nonInfo.slice(0, 10)) {
lines.push(` - [${f.severity}] ${f.title}`);
}
if (nonInfo.length > 10) {
lines.push(` ...and ${nonInfo.length - 10} more`);
}
} else {
lines.push(' Issues (0)');
}
lines.push('');
// Fix results
if (result.fixResult) {
const applied = result.fixResult.filter(r => r.status === 'applied').length;
lines.push(` Auto-fix: ${applied} fix(es) applied`);
lines.push('');
}
// Verdict
if (result.verdict === 'PASS') {
lines.push(' Self-audit: PASS');
lines.push(' (No critical or high findings)');
} else if (result.verdict === 'WARN') {
lines.push(' Self-audit: WARN');
lines.push(' (High-severity findings detected)');
} else {
lines.push(' Self-audit: FAIL');
lines.push(' (Critical findings detected)');
}
lines.push('');
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
return lines.join('\n');
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
const jsonMode = args.includes('--json');
const fixMode = args.includes('--fix');
const result = await runSelfAudit({ fix: fixMode });
if (jsonMode) {
const json = JSON.stringify(result, null, 2) + '\n';
await new Promise(resolve => process.stdout.write(json, resolve));
} else {
process.stderr.write('\n' + formatSelfAudit(result) + '\n');
}
process.exitCode = result.exitCode;
}
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -0,0 +1,224 @@
/**
* SET Scanner Settings.json Validator
* Validates schema, detects unknown/deprecated keys, type mismatches.
* Finding IDs: CA-SET-NNN
*/
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 { extractKeys } from './lib/string-utils.mjs';
const SCANNER = 'SET';
/** Known top-level settings.json keys (as of April 2026) */
const KNOWN_KEYS = new Set([
'agent', 'allowedChannelPlugins', 'allowedHttpHookUrls', 'allowedMcpServers',
'allowManagedHooksOnly', 'allowManagedMcpServersOnly', 'allowManagedPermissionRulesOnly',
'alwaysThinkingEnabled', 'apiKeyHelper', 'attribution', 'autoMemoryDirectory',
'autoMemoryEnabled', 'autoMode', 'autoUpdatesChannel', 'availableModels',
'awsAuthRefresh', 'awsCredentialExport', 'blockedMarketplaces', 'channelsEnabled',
'cleanupPeriodDays', 'claudeMdExcludes', 'companyAnnouncements', 'defaultShell',
'deniedMcpServers', 'disableAllHooks', 'disableAutoMode', 'disableDeepLinkRegistration',
'disabledMcpjsonServers', 'effortLevel', 'enableAllProjectMcpServers',
'enabledMcpjsonServers', 'enabledPlugins', 'env', 'extraKnownMarketplaces',
'fastModePerSessionOptIn', 'feedbackSurveyRate', 'fileSuggestion',
'forceLoginMethod', 'forceLoginOrgUUID', 'hooks', 'httpHookAllowedEnvVars',
'includeCoAuthoredBy', 'includeGitInstructions', 'language', 'model',
'modelOverrides', 'otelHeadersHelper', 'outputStyle', 'permissions',
'plansDirectory', 'pluginTrustMessage', 'prefersReducedMotion',
'respectGitignore', 'showClearContextOnPlanAccept', 'showThinkingSummaries',
'spinnerTipsEnabled', 'spinnerTipsOverride', 'spinnerVerbs', 'statusLine',
'strictKnownMarketplaces', 'useAutoModeDuringPlan', 'voiceEnabled',
'worktree', '$schema',
]);
/** Deprecated keys with migration info */
const DEPRECATED_KEYS = new Map([
['includeCoAuthoredBy', 'Use "attribution" instead'],
]);
/** Keys that require specific types */
const TYPE_CHECKS = new Map([
['alwaysThinkingEnabled', 'boolean'],
['autoMemoryEnabled', 'boolean'],
['channelsEnabled', 'boolean'],
['cleanupPeriodDays', 'number'],
['disableAllHooks', 'boolean'],
['effortLevel', 'string'],
['enableAllProjectMcpServers', 'boolean'],
['fastModePerSessionOptIn', 'boolean'],
['feedbackSurveyRate', 'number'],
['includeGitInstructions', 'boolean'],
['language', 'string'],
['model', 'string'],
['outputStyle', 'string'],
['prefersReducedMotion', 'boolean'],
['respectGitignore', 'boolean'],
['showThinkingSummaries', 'boolean'],
['spinnerTipsEnabled', 'boolean'],
['voiceEnabled', 'boolean'],
]);
/** Valid effortLevel values */
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
/**
* Scan all settings.json files discovered.
* @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 settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
const findings = [];
let filesScanned = 0;
if (settingsFiles.length === 0) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
for (const file of settingsFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const parsed = parseJson(content);
if (parsed === null) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid JSON in settings file',
description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`,
file: file.absPath,
recommendation: 'Fix JSON syntax errors. Use a JSON validator.',
autoFixable: false,
}));
continue;
}
// Check for unknown keys
for (const key of Object.keys(parsed)) {
if (!KNOWN_KEYS.has(key)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Unknown settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
file: file.absPath,
evidence: key,
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
autoFixable: false,
}));
}
}
// Check for deprecated keys
for (const [key, migration] of DEPRECATED_KEYS) {
if (parsed[key] !== undefined) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Deprecated settings key',
description: `${file.relPath}: "${key}" is deprecated. ${migration}`,
file: file.absPath,
evidence: `${key}: ${JSON.stringify(parsed[key])}`,
recommendation: migration,
autoFixable: true,
}));
}
}
// Type validation
for (const [key, expectedType] of TYPE_CHECKS) {
if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Type mismatch in settings',
description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`,
file: file.absPath,
evidence: `${key}: ${JSON.stringify(parsed[key])} (${typeof parsed[key]})`,
recommendation: `Change "${key}" to a ${expectedType} value.`,
autoFixable: true,
}));
}
}
// effortLevel value check
if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Invalid effortLevel value',
description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`,
file: file.absPath,
evidence: `effortLevel: "${parsed.effortLevel}"`,
recommendation: `Use one of: ${[...VALID_EFFORT_LEVELS].join(', ')}`,
autoFixable: true,
}));
}
// Missing $schema hint
if (!parsed.$schema) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Missing $schema reference',
description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`,
file: file.absPath,
recommendation: 'Add: "$schema": "https://json.schemastore.org/claude-code-settings.json"',
autoFixable: true,
}));
}
// Permissions checks
if (parsed.permissions) {
const perms = parsed.permissions;
if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'No deny rules configured',
description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`,
file: file.absPath,
recommendation: 'Add deny rules for sensitive files: "deny": ["Read(./.env)", "Read(./secrets/**)"]',
autoFixable: false,
}));
}
if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'No allow rules configured',
description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`,
file: file.absPath,
recommendation: 'Add allow rules for common tools: "allow": ["Bash(npm run *)", "Read(src/**)"]',
autoFixable: false,
}));
}
}
// hooks checks (basic — detailed in hook-validator)
if (parsed.hooks) {
if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Hooks configured as array instead of object',
description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`,
file: file.absPath,
evidence: '"hooks": [...]',
recommendation: 'Change to object format: "hooks": { "PreToolUse": [...] }',
autoFixable: true,
}));
}
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}