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,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;
}