/** * 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(`
`); lines.push(`${sr.scanner} — ${sr.findings.length} finding(s)`); 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('
'); 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(`
`); lines.push(`${p.name} — ${p.findings.length} issue(s)`); lines.push(''); for (const f of p.findings.slice(0, MAX_FINDINGS_PER_SCANNER)) { lines.push(`- \`[${f.severity}]\` ${f.title}`); } lines.push(''); lines.push('
'); 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; }