278 lines
9 KiB
JavaScript
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' ? '↑'
|
|
: summary.trend === 'degrading' ? '↓' : '→';
|
|
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;
|
|
}
|