#!/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, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readdir, readFile, stat } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; 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'; import { parseJson } from './lib/yaml-parser.mjs'; import { humanizeEnvelope, humanizeFindings } from './lib/humanizer.mjs'; const execFileAsync = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = resolve(__dirname, '..'); // Scanner-shape detection: files in scanners/ that export `scan` and are not // support modules. Matches the detection rule from v5 plan Step 16. // // `plugin-health-scanner.mjs` is excluded from the main scanner count: it has // `export async function scan` but it runs standalone (not via scan-orchestrator) // and is documented under "Standalone Scanner" in README/CLAUDE.md. The badge // `scanners-12` reflects the orchestrated scanners that contribute to posture // scoring. const SCANNER_EXCLUDES = new Set([ 'scan-orchestrator.mjs', 'self-audit.mjs', 'whats-active.mjs', 'plugin-health-scanner.mjs', ]); function isScannerShape(name, content) { if (!name.endsWith('.mjs')) return false; if (SCANNER_EXCLUDES.has(name)) return false; if (/-cli\.mjs$/.test(name)) return false; if (/-engine\.mjs$/.test(name)) return false; return /export\s+async\s+function\s+scan\b/.test(content); } async function safeListDir(path) { try { return await readdir(path, { withFileTypes: true }); } catch { return []; } } async function countScannerShape(scannersDir) { let count = 0; for (const e of await safeListDir(scannersDir)) { if (!e.isFile()) continue; if (!e.name.endsWith('.mjs')) continue; let content = ''; try { content = await readFile(join(scannersDir, e.name), 'utf-8'); } catch { continue; } if (isScannerShape(e.name, content)) count++; } return count; } async function countMdFiles(dir) { let count = 0; for (const e of await safeListDir(dir)) { if (e.isFile() && e.name.endsWith('.md')) count++; } return count; } async function countTestFiles(testsRoot) { let count = 0; async function walk(dir) { for (const e of await safeListDir(dir)) { const full = join(dir, e.name); if (e.isDirectory()) await walk(full); else if (e.isFile() && e.name.endsWith('.test.mjs')) count++; } } await walk(testsRoot); return count; } // Run the test suite in a subprocess and parse the `ℹ tests N` line emitted // by node:test. Used for badge accuracy under --check-readme. Slow (~15s on // the full plugin) but produces the canonical case count rather than an // approximation. Returns null on failure so the caller can fall back to // file count without crashing the audit. async function countTestCases(pluginRoot) { try { const { stdout } = await execFileAsync( process.execPath, ['--test', 'tests/**/*.test.mjs'], { cwd: pluginRoot, timeout: 60000, maxBuffer: 10 * 1024 * 1024 }, ); const match = stdout.match(/^[^\n]*tests\s+(\d+)\s*$/m); return match ? Number(match[1]) : null; } catch (err) { // node --test exits non-zero when tests fail; the count line is still // present on stdout. Re-parse it from the captured output. const stdout = err?.stdout || ''; const match = stdout.match(/^[^\n]*tests\s+(\d+)\s*$/m); return match ? Number(match[1]) : null; } } async function countHookEntries(hooksJsonPath) { let content; try { content = await readFile(hooksJsonPath, 'utf-8'); } catch { return 0; } const parsed = parseJson(content); const hooks = parsed?.hooks || parsed; if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return 0; let n = 0; for (const handlers of Object.values(hooks)) { if (!Array.isArray(handlers)) continue; for (const group of handlers) { if (!Array.isArray(group?.hooks)) continue; n += group.hooks.length; } } return n; } /** * Parse a numeric badge value from a README badge URL via line-anchored * substring detection. Returns null if no badge for `kind` is found. * Pattern: `badge/-(+)?-` — case-insensitive. */ function parseBadgeNumber(readme, kind) { const lines = readme.split('\n'); const rx = new RegExp(`badge\\/${kind}-([0-9]+)\\+?-`, 'i'); for (const line of lines) { const m = line.match(rx); if (m) return Number(m[1]); } return null; } /** * Compare README badge counts against filesystem-measured counts (v5 F6). * Filesystem counts are the source of truth. * * @param {string} pluginDir * @returns {Promise<{passed: boolean, mismatches: Array<{kind:string, expected:number, foundInReadme:number}>, counts: object, badges: object}>} */ export async function checkReadmeBadges(pluginDir) { const testCases = await countTestCases(pluginDir); const counts = { scanners: await countScannerShape(join(pluginDir, 'scanners')), commands: await countMdFiles(join(pluginDir, 'commands')), agents: await countMdFiles(join(pluginDir, 'agents')), hooks: await countHookEntries(join(pluginDir, 'hooks', 'hooks.json')), tests: testCases ?? await countTestFiles(join(pluginDir, 'tests')), knowledge: await countMdFiles(join(pluginDir, 'knowledge')), }; let readme = ''; try { readme = await readFile(join(pluginDir, 'README.md'), 'utf-8'); } catch { /* missing */ } const badges = { scanners: parseBadgeNumber(readme, 'scanners'), commands: parseBadgeNumber(readme, 'commands'), agents: parseBadgeNumber(readme, 'agents'), hooks: parseBadgeNumber(readme, 'hooks'), tests: parseBadgeNumber(readme, 'tests'), knowledge: parseBadgeNumber(readme, 'knowledge'), }; const mismatches = []; for (const kind of Object.keys(counts)) { if (badges[kind] === null) continue; // no badge for this kind — silent if (counts[kind] !== badges[kind]) { mismatches.push({ kind, expected: counts[kind], foundInReadme: badges[kind] }); } } return { passed: mismatches.length === 0, mismatches, counts, badges }; } /** * Run self-audit on this plugin. * @param {object} [opts] * @param {boolean} [opts.fix=false] - Run fix-engine on auto-fixable findings * @param {boolean} [opts.checkReadme=false] - Verify README badge counts (v5 F6) * @returns {Promise} 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 } } // 7. Optional README badge check (v5 F6) let readmeCheck; if (opts.checkReadme) { readmeCheck = await checkReadmeBadges(pluginDir); } const out = { pluginDir, configGrade, configScore: avgScore, pluginGrade, pluginScore, configEnvelope, pluginHealthResult, allFindings, exitCode, verdict, fixResult, }; if (readmeCheck) out.readmeCheck = readmeCheck; return out; } /** * Format self-audit result for terminal display. * @param {object} result - From runSelfAudit() * @returns {string} */ export function formatSelfAudit(result) { // Humanize findings for terminal-output path only. JSON path (--json) is // unaffected \u2014 it serializes the original `result` object directly. const humanizedConfigEnv = humanizeEnvelope(result.configEnvelope); const humanizedAllFindings = [ ...humanizedConfigEnv.scanners.flatMap(s => s.findings), ...humanizeFindings(result.pluginHealthResult.findings), ]; 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 = humanizedAllFindings.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 checkReadmeMode = args.includes('--check-readme'); const result = await runSelfAudit({ fix: fixMode, checkReadme: checkReadmeMode }); 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); }); }