UNPKG

@sun-asterisk/sunlint

Version:

☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

840 lines (731 loc) 23 kB
/** * HTML Report Generator * Generate standalone HTML report with embedded CSS for SunLint results */ /** * Generate HTML report from violations data * @param {Array} violations - Array of violation objects * @param {Object} metadata - Report metadata * @param {Object} metadata.score - Scoring summary * @param {Object} metadata.gitInfo - Git information * @param {string} metadata.timestamp - Report timestamp * @returns {string} Complete HTML report */ function generateHTMLReport(violations, metadata = {}) { const { score = {}, gitInfo = {}, timestamp = new Date().toISOString() } = metadata; // Calculate statistics const stats = calculateStatistics(violations); // Generate HTML sections const html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SunLint Report - ${gitInfo.repository_name || 'Project'}</title> ${embedCSS()} </head> <body> <div class="container"> ${generateHeader(gitInfo, timestamp)} ${generateSummary(stats, score)} ${generateByFileSection(violations, stats.fileGroups)} ${generateByRuleSection(violations, stats.ruleGroups)} ${generateFooter(timestamp)} </div> ${embedJavaScript()} </body> </html>`; return html; } /** * Calculate statistics from violations * @param {Array} violations - Violations array * @returns {Object} Statistics object */ function calculateStatistics(violations) { const totalViolations = violations.length; const errorCount = violations.filter(v => v.severity === 'error').length; const warningCount = violations.filter(v => v.severity === 'warning').length; // Group by file const fileGroups = {}; for (const v of violations) { if (!fileGroups[v.file]) { fileGroups[v.file] = []; } fileGroups[v.file].push(v); } // Group by rule const ruleGroups = {}; for (const v of violations) { if (!ruleGroups[v.rule]) { ruleGroups[v.rule] = []; } ruleGroups[v.rule].push(v); } const filesWithIssues = Object.keys(fileGroups).length; return { totalViolations, errorCount, warningCount, filesWithIssues, fileGroups, ruleGroups }; } /** * Generate HTML header * @param {Object} gitInfo - Git information * @param {string} timestamp - Report timestamp * @returns {string} Header HTML */ function generateHeader(gitInfo, timestamp) { const repoName = gitInfo.repository_name || 'Project'; const branch = gitInfo.branch || 'Unknown'; const commit = gitInfo.commit_hash ? gitInfo.commit_hash.substring(0, 7) : 'N/A'; return ` <header class="header"> <div class="header-content"> <h1> <span class="logo">🌟</span> SunLint Code Quality Report </h1> <div class="header-meta"> <div class="meta-item"> <span class="meta-label">Repository:</span> <span class="meta-value">${escapeHTML(repoName)}</span> </div> <div class="meta-item"> <span class="meta-label">Branch:</span> <span class="meta-value">${escapeHTML(branch)}</span> </div> <div class="meta-item"> <span class="meta-label">Commit:</span> <span class="meta-value">${escapeHTML(commit)}</span> </div> <div class="meta-item"> <span class="meta-label">Generated:</span> <span class="meta-value">${new Date(timestamp).toLocaleString()}</span> </div> </div> </div> </header>`; } /** * Generate summary section * @param {Object} stats - Statistics object * @param {Object} score - Score information * @returns {string} Summary HTML */ function generateSummary(stats, score) { const { totalViolations, errorCount, warningCount, filesWithIssues } = stats; const status = errorCount > 0 ? 'failed' : warningCount > 0 ? 'warning' : 'passed'; const statusIcon = errorCount > 0 ? '❌' : warningCount > 0 ? '⚠️' : '✅'; const statusText = errorCount > 0 ? 'Failed' : warningCount > 0 ? 'Passed with Warnings' : 'Passed'; const scoreValue = score.score !== undefined ? score.score : 'N/A'; const grade = score.grade || 'N/A'; return ` <section class="summary"> <div class="summary-header"> <div class="status-badge status-${status}"> <span class="status-icon">${statusIcon}</span> <span class="status-text">${statusText}</span> </div> ${scoreValue !== 'N/A' ? ` <div class="score-display"> <div class="score-value">${scoreValue}</div> <div class="score-grade">${grade}</div> </div> ` : ''} </div> <div class="summary-stats"> <div class="stat-card"> <div class="stat-value">${totalViolations}</div> <div class="stat-label">Total Violations</div> </div> <div class="stat-card stat-error"> <div class="stat-value">${errorCount}</div> <div class="stat-label">Errors</div> </div> <div class="stat-card stat-warning"> <div class="stat-value">${warningCount}</div> <div class="stat-label">Warnings</div> </div> <div class="stat-card"> <div class="stat-value">${filesWithIssues}</div> <div class="stat-label">Files with Issues</div> </div> </div> </section>`; } /** * Generate violations by file section * @param {Array} violations - Violations array * @param {Object} fileGroups - Grouped by file * @returns {string} By file HTML */ function generateByFileSection(violations, fileGroups) { if (violations.length === 0) { return ` <section class="section"> <h2>✅ Great Job!</h2> <p class="no-violations">No coding standard violations found.</p> </section>`; } const sortedFiles = Object.entries(fileGroups) .sort((a, b) => b[1].length - a[1].length); let html = ` <section class="section"> <h2>📁 Violations by File</h2> <div class="controls"> <input type="text" id="fileSearch" class="search-box" placeholder="Search files..."> <select id="fileFilter" class="filter-select"> <option value="all">All Severities</option> <option value="error">Errors Only</option> <option value="warning">Warnings Only</option> </select> </div> <table class="violations-table" id="fileTable"> <thead> <tr> <th onclick="sortTable('fileTable', 0)">File <span class="sort-icon"></span></th> <th onclick="sortTable('fileTable', 1)">Errors <span class="sort-icon"></span></th> <th onclick="sortTable('fileTable', 2)">Warnings <span class="sort-icon"></span></th> <th onclick="sortTable('fileTable', 3)">Total <span class="sort-icon"></span></th> <th>Details</th> </tr> </thead> <tbody>`; for (const [file, fileViolations] of sortedFiles) { const fileErrors = fileViolations.filter(v => v.severity === 'error').length; const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length; const total = fileViolations.length; const fileId = `file-${Buffer.from(file).toString('base64').replace(/=/g, '')}`; html += ` <tr class="file-row" data-errors="${fileErrors}" data-warnings="${fileWarnings}"> <td class="file-path"><code>${escapeHTML(file)}</code></td> <td class="count-cell ${fileErrors > 0 ? 'has-errors' : ''}">${fileErrors}</td> <td class="count-cell ${fileWarnings > 0 ? 'has-warnings' : ''}">${fileWarnings}</td> <td class="count-cell">${total}</td> <td> <button class="details-btn" onclick="toggleDetails('${fileId}')"> Show Details <span class="arrow"></span> </button> </td> </tr> <tr class="details-row" id="${fileId}" style="display: none;"> <td colspan="5"> <div class="details-content"> ${generateFileDetails(fileViolations)} </div> </td> </tr>`; } html += ` </tbody> </table> </section>`; return html; } /** * Generate file details * @param {Array} violations - Violations for a file * @returns {string} Details HTML */ function generateFileDetails(violations) { let html = '<ul class="violation-list">'; for (const v of violations) { const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning'; const severityIcon = v.severity === 'error' ? '🔴' : '🟡'; html += ` <li class="violation-item"> <span class="${severityClass}">${severityIcon} ${v.severity.toUpperCase()}</span> <span class="violation-line">Line ${v.line}</span> <span class="violation-rule">[${escapeHTML(v.rule)}]</span> <span class="violation-message">${escapeHTML(v.message)}</span> </li>`; } html += '</ul>'; return html; } /** * Generate violations by rule section * @param {Array} violations - Violations array * @param {Object} ruleGroups - Grouped by rule * @returns {string} By rule HTML */ function generateByRuleSection(violations, ruleGroups) { if (violations.length === 0) { return ''; } const sortedRules = Object.entries(ruleGroups) .sort((a, b) => b[1].length - a[1].length); let html = ` <section class="section"> <h2>📋 Violations by Rule</h2> <table class="violations-table" id="ruleTable"> <thead> <tr> <th onclick="sortTable('ruleTable', 0)">Rule <span class="sort-icon"></span></th> <th onclick="sortTable('ruleTable', 1)">Errors <span class="sort-icon"></span></th> <th onclick="sortTable('ruleTable', 2)">Warnings <span class="sort-icon"></span></th> <th onclick="sortTable('ruleTable', 3)">Total <span class="sort-icon"></span></th> <th>Locations</th> </tr> </thead> <tbody>`; for (const [ruleId, ruleViolations] of sortedRules) { const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length; const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length; const total = ruleViolations.length; const ruleIdSafe = `rule-${Buffer.from(ruleId).toString('base64').replace(/=/g, '')}`; html += ` <tr class="rule-row"> <td class="rule-id"><code>${escapeHTML(ruleId)}</code></td> <td class="count-cell ${ruleErrors > 0 ? 'has-errors' : ''}">${ruleErrors}</td> <td class="count-cell ${ruleWarnings > 0 ? 'has-warnings' : ''}">${ruleWarnings}</td> <td class="count-cell">${total}</td> <td> <button class="details-btn" onclick="toggleDetails('${ruleIdSafe}')"> Show Locations <span class="arrow"></span> </button> </td> </tr> <tr class="details-row" id="${ruleIdSafe}" style="display: none;"> <td colspan="5"> <div class="details-content"> ${generateRuleDetails(ruleViolations)} </div> </td> </tr>`; } html += ` </tbody> </table> </section>`; return html; } /** * Generate rule details * @param {Array} violations - Violations for a rule * @returns {string} Details HTML */ function generateRuleDetails(violations) { let html = '<ul class="location-list">'; for (const v of violations) { const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning'; const severityIcon = v.severity === 'error' ? '🔴' : '🟡'; html += ` <li class="location-item"> <span class="${severityClass}">${severityIcon}</span> <code class="location-path">${escapeHTML(v.file)}:${v.line}</code> <span class="location-message">${escapeHTML(v.message)}</span> </li>`; } html += '</ul>'; return html; } /** * Generate footer * @param {string} timestamp - Report timestamp * @returns {string} Footer HTML */ function generateFooter(timestamp) { return ` <footer class="footer"> <p> Generated by <a href="https://github.com/sun-asterisk/engineer-excellence" target="_blank">SunLint</a> on ${new Date(timestamp).toLocaleString()} </p> </footer>`; } /** * Embed CSS styles * @returns {string} Style tag */ function embedCSS() { return `<style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; } .container { max-width: 1400px; margin: 0 auto; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; } .header-content h1 { font-size: 28px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; } .logo { font-size: 32px; } .header-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; } .meta-item { background: rgba(255,255,255,0.1); padding: 10px 15px; border-radius: 5px; } .meta-label { display: block; font-size: 12px; opacity: 0.8; margin-bottom: 5px; } .meta-value { display: block; font-size: 14px; font-weight: 600; } .summary { padding: 30px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; } .summary-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; } .status-badge { display: inline-flex; align-items: center; gap: 10px; padding: 12px 24px; border-radius: 8px; font-size: 20px; font-weight: 600; } .status-passed { background: #d1fae5; color: #065f46; } .status-warning { background: #fef3c7; color: #92400e; } .status-failed { background: #fee2e2; color: #991b1b; } .score-display { text-align: center; } .score-value { font-size: 48px; font-weight: bold; color: #667eea; } .score-grade { font-size: 24px; color: #666; margin-top: -5px; } .summary-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; } .stat-card { background: white; padding: 20px; border-radius: 8px; text-align: center; border: 2px solid #e5e7eb; } .stat-card.stat-error { border-color: #fca5a5; } .stat-card.stat-warning { border-color: #fcd34d; } .stat-value { font-size: 36px; font-weight: bold; color: #667eea; margin-bottom: 5px; } .stat-card.stat-error .stat-value { color: #dc2626; } .stat-card.stat-warning .stat-value { color: #f59e0b; } .stat-label { font-size: 14px; color: #666; } .section { padding: 30px; border-bottom: 1px solid #e5e7eb; } .section h2 { font-size: 22px; margin-bottom: 20px; color: #1f2937; } .controls { display: flex; gap: 15px; margin-bottom: 20px; } .search-box, .filter-select { padding: 10px 15px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 14px; } .search-box { flex: 1; max-width: 400px; } .filter-select { min-width: 150px; } .violations-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; } .violations-table th { background: #f9fafb; padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #e5e7eb; cursor: pointer; user-select: none; } .violations-table th:hover { background: #f3f4f6; } .sort-icon { font-size: 10px; opacity: 0.5; margin-left: 5px; } .violations-table td { padding: 12px; border-bottom: 1px solid #e5e7eb; } .file-path, .rule-id { font-family: 'Courier New', monospace; font-size: 13px; } .count-cell { text-align: center; font-weight: 600; } .count-cell.has-errors { color: #dc2626; } .count-cell.has-warnings { color: #f59e0b; } .details-btn { background: #667eea; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; gap: 5px; } .details-btn:hover { background: #5568d3; } .arrow { font-size: 10px; transition: transform 0.2s; } .details-row td { background: #f9fafb; padding: 0; } .details-content { padding: 20px; } .violation-list, .location-list { list-style: none; } .violation-item, .location-item { padding: 10px; margin-bottom: 8px; background: white; border-left: 3px solid #e5e7eb; border-radius: 4px; display: flex; gap: 10px; align-items: center; } .severity-error { color: #dc2626; font-weight: 600; } .severity-warning { color: #f59e0b; font-weight: 600; } .violation-line { background: #e5e7eb; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-family: monospace; } .violation-rule { color: #667eea; font-weight: 600; font-size: 12px; } .violation-message, .location-message { flex: 1; color: #666; font-size: 14px; } .location-path { font-family: 'Courier New', monospace; background: #f3f4f6; padding: 2px 8px; border-radius: 3px; font-size: 12px; } .no-violations { text-align: center; padding: 40px; font-size: 18px; color: #10b981; } .footer { padding: 20px; text-align: center; color: #666; font-size: 14px; background: #f9fafb; } .footer a { color: #667eea; text-decoration: none; } .footer a:hover { text-decoration: underline; } @media print { body { background: white; padding: 0; } .container { box-shadow: none; } .details-btn { display: none; } .details-row { display: table-row !important; } } @media (max-width: 768px) { .header-meta { grid-template-columns: 1fr; } .summary-stats { grid-template-columns: 1fr; } .controls { flex-direction: column; } .search-box { max-width: 100%; } } </style>`; } /** * Embed JavaScript * @returns {string} Script tag */ function embedJavaScript() { return `<script> // Toggle details row function toggleDetails(id) { const row = document.getElementById(id); const btn = event.target.closest('.details-btn'); const arrow = btn.querySelector('.arrow'); if (row.style.display === 'none') { row.style.display = 'table-row'; arrow.style.transform = 'rotate(180deg)'; btn.innerHTML = 'Hide Details <span class="arrow" style="transform: rotate(180deg);">▼</span>'; } else { row.style.display = 'none'; arrow.style.transform = 'rotate(0deg)'; btn.innerHTML = 'Show Details <span class="arrow">▼</span>'; } } // Sort table function sortTable(tableId, columnIndex) { const table = document.getElementById(tableId); const tbody = table.querySelector('tbody'); const rows = Array.from(tbody.querySelectorAll('tr')).filter(r => !r.classList.contains('details-row')); const isNumeric = columnIndex > 0; rows.sort((a, b) => { const aVal = a.cells[columnIndex].textContent.trim(); const bVal = b.cells[columnIndex].textContent.trim(); if (isNumeric) { return parseInt(bVal) - parseInt(aVal); } return aVal.localeCompare(bVal); }); rows.forEach(row => { const detailsRow = row.nextElementSibling; tbody.appendChild(row); if (detailsRow && detailsRow.classList.contains('details-row')) { tbody.appendChild(detailsRow); } }); } // File search const fileSearch = document.getElementById('fileSearch'); if (fileSearch) { fileSearch.addEventListener('input', function() { const searchTerm = this.value.toLowerCase(); const rows = document.querySelectorAll('#fileTable tbody tr.file-row'); rows.forEach(row => { const file = row.querySelector('.file-path').textContent.toLowerCase(); const detailsRow = row.nextElementSibling; if (file.includes(searchTerm)) { row.style.display = ''; if (detailsRow && detailsRow.style.display !== 'none') { detailsRow.style.display = ''; } } else { row.style.display = 'none'; if (detailsRow) { detailsRow.style.display = 'none'; } } }); }); } // File filter const fileFilter = document.getElementById('fileFilter'); if (fileFilter) { fileFilter.addEventListener('change', function() { const filter = this.value; const rows = document.querySelectorAll('#fileTable tbody tr.file-row'); rows.forEach(row => { const errors = parseInt(row.dataset.errors); const warnings = parseInt(row.dataset.warnings); const detailsRow = row.nextElementSibling; let show = true; if (filter === 'error' && errors === 0) show = false; if (filter === 'warning' && warnings === 0) show = false; if (show) { row.style.display = ''; } else { row.style.display = 'none'; if (detailsRow) { detailsRow.style.display = 'none'; } } }); }); } </script>`; } /** * Escape HTML special characters * @param {string} text - Text to escape * @returns {string} Escaped text */ function escapeHTML(text) { if (!text) return ''; return String(text) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } module.exports = { generateHTMLReport };