ktg-plugin-marketplace/plugins/config-audit/scanners/self-audit.mjs
Kjell Tore Guttormsen 3c79f95e9a feat(config-audit): self-audit --check-readme flag (v5 F6) [skip-docs]
Filesystem counts are the source of truth; README badges parsed via
line-anchored substring (badge/<kind>-<N>-...). Emits readmeCheck object
with counts/badges/mismatches.

CLI: node scanners/self-audit.mjs --check-readme [--json]
API: runSelfAudit({ checkReadme: true }) → result.readmeCheck
Helper: checkReadmeBadges(pluginDir) for per-fixture testing

New fixture: readme-desynced/ (commands/foo + bar, README claims 1).

Note: alpha phase does NOT require result.readmeCheck.passed === true.
Self-test of real plugin currently fails (scanners 10 vs 9, tests 31 vs 543);
will be reconciled in Session 5 Step 28 (README sync).

582 → 586 tests, all green.
2026-05-01 07:09:26 +02:00

311 lines
11 KiB
JavaScript

#!/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 { 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';
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.
const SCANNER_EXCLUDES = new Set([
'scan-orchestrator.mjs',
'self-audit.mjs',
'whats-active.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;
}
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/<kind>-<NUMBER>(+)?-<color>` — 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 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: 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<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
}
}
// 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) {
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 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);
});
}