ktg-plugin-marketplace/plugins/config-audit/scanners/lib/report-generator.mjs

278 lines
9 KiB
JavaScript

/**
* 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;
}