@sun-asterisk/sunlint 1.3.34 → 1.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/core/architecture-integration.js +16 -7
  2. package/core/auto-performance-manager.js +1 -1
  3. package/core/cli-action-handler.js +92 -2
  4. package/core/cli-program.js +96 -138
  5. package/core/file-targeting-service.js +62 -4
  6. package/core/git-utils.js +19 -12
  7. package/core/github-annotate-service.js +326 -11
  8. package/core/html-report-generator.js +326 -731
  9. package/core/impact-integration.js +433 -0
  10. package/core/output-service.js +293 -21
  11. package/core/scoring-service.js +3 -2
  12. package/engines/arch-detect/core/analyzer.js +413 -0
  13. package/engines/arch-detect/core/index.js +22 -0
  14. package/engines/arch-detect/engine/hybrid-detector.js +176 -0
  15. package/engines/arch-detect/engine/index.js +24 -0
  16. package/engines/arch-detect/engine/rule-executor.js +228 -0
  17. package/engines/arch-detect/engine/score-calculator.js +214 -0
  18. package/engines/arch-detect/engine/violation-detector.js +616 -0
  19. package/engines/arch-detect/index.js +50 -0
  20. package/engines/arch-detect/rules/base-rule.js +187 -0
  21. package/engines/arch-detect/rules/index.js +35 -0
  22. package/engines/arch-detect/rules/layered/index.js +28 -0
  23. package/engines/arch-detect/rules/layered/l001-presentation-layer.js +237 -0
  24. package/engines/arch-detect/rules/layered/l002-business-layer.js +215 -0
  25. package/engines/arch-detect/rules/layered/l003-data-layer.js +229 -0
  26. package/engines/arch-detect/rules/layered/l004-model-layer.js +204 -0
  27. package/engines/arch-detect/rules/layered/l005-layer-separation.js +215 -0
  28. package/engines/arch-detect/rules/layered/l006-dependency-direction.js +221 -0
  29. package/engines/arch-detect/rules/layered/layered-rules-collection.js +445 -0
  30. package/engines/arch-detect/rules/modular/index.js +27 -0
  31. package/engines/arch-detect/rules/modular/m001-feature-modules.js +238 -0
  32. package/engines/arch-detect/rules/modular/m002-core-module.js +169 -0
  33. package/engines/arch-detect/rules/modular/m003-module-declaration.js +186 -0
  34. package/engines/arch-detect/rules/modular/m004-public-api.js +171 -0
  35. package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +220 -0
  36. package/engines/arch-detect/rules/modular/modular-rules-collection.js +357 -0
  37. package/engines/arch-detect/rules/presentation/index.js +27 -0
  38. package/engines/arch-detect/rules/presentation/pr001-view-layer.js +221 -0
  39. package/engines/arch-detect/rules/presentation/pr002-presentation-logic.js +192 -0
  40. package/engines/arch-detect/rules/presentation/pr004-data-binding.js +187 -0
  41. package/engines/arch-detect/rules/presentation/pr006-router-layer.js +185 -0
  42. package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +181 -0
  43. package/engines/arch-detect/rules/presentation/presentation-rules-collection.js +507 -0
  44. package/engines/arch-detect/rules/project-scanner/index.js +31 -0
  45. package/engines/arch-detect/rules/project-scanner/ps001-project-root.js +213 -0
  46. package/engines/arch-detect/rules/project-scanner/ps002-language-detection.js +192 -0
  47. package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +339 -0
  48. package/engines/arch-detect/rules/project-scanner/ps004-build-system.js +171 -0
  49. package/engines/arch-detect/rules/project-scanner/ps005-source-directory.js +163 -0
  50. package/engines/arch-detect/rules/project-scanner/ps006-test-directory.js +184 -0
  51. package/engines/arch-detect/rules/project-scanner/ps007-documentation.js +149 -0
  52. package/engines/arch-detect/rules/project-scanner/ps008-cicd-detection.js +163 -0
  53. package/engines/arch-detect/rules/project-scanner/ps009-code-quality.js +152 -0
  54. package/engines/arch-detect/rules/project-scanner/ps010-statistics.js +180 -0
  55. package/engines/arch-detect/rules/rule-registry.js +111 -0
  56. package/engines/arch-detect/types/context.types.js +60 -0
  57. package/engines/arch-detect/types/enums.js +161 -0
  58. package/engines/arch-detect/types/index.js +25 -0
  59. package/engines/arch-detect/types/result.types.js +7 -0
  60. package/engines/arch-detect/types/rule.types.js +7 -0
  61. package/engines/arch-detect/utils/file-scanner.js +411 -0
  62. package/engines/arch-detect/utils/index.js +23 -0
  63. package/engines/arch-detect/utils/pattern-matcher.js +328 -0
  64. package/engines/impact/cli.js +106 -0
  65. package/engines/impact/config/default-config.js +54 -0
  66. package/engines/impact/core/change-detector.js +258 -0
  67. package/engines/impact/core/detectors/database-detector.js +1317 -0
  68. package/engines/impact/core/detectors/endpoint-detector.js +55 -0
  69. package/engines/impact/core/impact-analyzer.js +124 -0
  70. package/engines/impact/core/report-generator.js +462 -0
  71. package/engines/impact/core/utils/ast-parser.js +241 -0
  72. package/engines/impact/core/utils/dependency-graph.js +159 -0
  73. package/engines/impact/core/utils/file-utils.js +116 -0
  74. package/engines/impact/core/utils/git-utils.js +203 -0
  75. package/engines/impact/core/utils/logger.js +13 -0
  76. package/engines/impact/core/utils/method-call-graph.js +1192 -0
  77. package/engines/impact/index.js +135 -0
  78. package/engines/impact/package.json +29 -0
  79. package/package.json +18 -43
  80. package/scripts/build-release.sh +0 -0
  81. package/scripts/copy-impact-analyzer.js +135 -0
  82. package/scripts/install.sh +0 -0
  83. package/scripts/manual-release.sh +0 -0
  84. package/scripts/pre-release-test.sh +0 -0
  85. package/scripts/prepare-release.sh +0 -0
  86. package/scripts/quick-performance-test.js +0 -0
  87. package/scripts/setup-github-registry.sh +0 -0
  88. package/scripts/trigger-release.sh +0 -0
  89. package/scripts/verify-install.sh +0 -0
  90. package/templates/combined-report.html +1418 -0
@@ -1,49 +1,52 @@
1
1
  /**
2
2
  * HTML Report Generator
3
- * Generate standalone HTML report with embedded CSS for SunLint results
3
+ * Generate HTML report from template for SunLint results
4
4
  */
5
5
 
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
6
9
  /**
7
- * Generate HTML report from violations data
10
+ * Generate HTML report from violations data using template
8
11
  * @param {Array} violations - Array of violation objects
9
12
  * @param {Object} metadata - Report metadata
10
13
  * @param {Object} metadata.score - Scoring summary
11
14
  * @param {Object} metadata.gitInfo - Git information
12
15
  * @param {string} metadata.timestamp - Report timestamp
16
+ * @param {Object} metadata.architecture - Architecture analysis data
17
+ * @param {Object} metadata.impact - Impact analysis data
13
18
  * @returns {string} Complete HTML report
14
19
  */
15
20
  function generateHTMLReport(violations, metadata = {}) {
16
21
  const {
17
22
  score = {},
18
23
  gitInfo = {},
19
- timestamp = new Date().toISOString()
24
+ timestamp = new Date().toISOString(),
25
+ architecture = null,
26
+ impact = null,
27
+ aiSummary = null
20
28
  } = metadata;
21
29
 
30
+ // Load template
31
+ const templatePath = path.join(__dirname, '..', 'templates', 'combined-report.html');
32
+ let template;
33
+
34
+ try {
35
+ template = fs.readFileSync(templatePath, 'utf8');
36
+ } catch (error) {
37
+ console.error(`Failed to load template: ${error.message}`);
38
+ // Fallback to basic HTML
39
+ return generateFallbackHTML(violations, metadata);
40
+ }
41
+
22
42
  // Calculate statistics
23
43
  const stats = calculateStatistics(violations);
24
44
 
25
- // Generate HTML sections
26
- const html = `<!DOCTYPE html>
27
- <html lang="en">
28
- <head>
29
- <meta charset="UTF-8">
30
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
- <title>SunLint Report - ${gitInfo.repository_name || 'Project'}</title>
32
- ${embedCSS()}
33
- </head>
34
- <body>
35
- <div class="container">
36
- ${generateHeader(gitInfo, timestamp)}
37
- ${generateSummary(stats, score)}
38
- ${generateByFileSection(violations, stats.fileGroups)}
39
- ${generateByRuleSection(violations, stats.ruleGroups)}
40
- ${generateFooter(timestamp)}
41
- </div>
42
- ${embedJavaScript()}
43
- </body>
44
- </html>`;
45
+ // Prepare template data
46
+ const data = prepareTemplateData(stats, score, architecture, impact, gitInfo, timestamp, violations, aiSummary);
45
47
 
46
- return html;
48
+ // Render template
49
+ return renderTemplate(template, data);
47
50
  }
48
51
 
49
52
  /**
@@ -65,15 +68,6 @@ function calculateStatistics(violations) {
65
68
  fileGroups[v.file].push(v);
66
69
  }
67
70
 
68
- // Group by rule
69
- const ruleGroups = {};
70
- for (const v of violations) {
71
- if (!ruleGroups[v.rule]) {
72
- ruleGroups[v.rule] = [];
73
- }
74
- ruleGroups[v.rule].push(v);
75
- }
76
-
77
71
  const filesWithIssues = Object.keys(fileGroups).length;
78
72
 
79
73
  return {
@@ -81,748 +75,302 @@ function calculateStatistics(violations) {
81
75
  errorCount,
82
76
  warningCount,
83
77
  filesWithIssues,
84
- fileGroups,
85
- ruleGroups
78
+ fileGroups
86
79
  };
87
80
  }
88
81
 
89
82
  /**
90
- * Generate HTML header
91
- * @param {Object} gitInfo - Git information
92
- * @param {string} timestamp - Report timestamp
93
- * @returns {string} Header HTML
83
+ * Prepare data for template rendering
94
84
  */
95
- function generateHeader(gitInfo, timestamp) {
96
- const repoName = gitInfo.repository_name || 'Project';
97
- const branch = gitInfo.branch || 'Unknown';
98
- const commit = gitInfo.commit_hash ? gitInfo.commit_hash.substring(0, 7) : 'N/A';
99
-
100
- return `
101
- <header class="header">
102
- <div class="header-content">
103
- <h1>
104
- <span class="logo">🌟</span>
105
- SunLint Code Quality Report
106
- </h1>
107
- <div class="header-meta">
108
- <div class="meta-item">
109
- <span class="meta-label">Repository:</span>
110
- <span class="meta-value">${escapeHTML(repoName)}</span>
111
- </div>
112
- <div class="meta-item">
113
- <span class="meta-label">Branch:</span>
114
- <span class="meta-value">${escapeHTML(branch)}</span>
115
- </div>
116
- <div class="meta-item">
117
- <span class="meta-label">Commit:</span>
118
- <span class="meta-value">${escapeHTML(commit)}</span>
119
- </div>
120
- <div class="meta-item">
121
- <span class="meta-label">Generated:</span>
122
- <span class="meta-value">${new Date(timestamp).toLocaleString()}</span>
123
- </div>
124
- </div>
125
- </div>
126
- </header>`;
127
- }
128
-
129
- /**
130
- * Generate summary section
131
- * @param {Object} stats - Statistics object
132
- * @param {Object} score - Score information
133
- * @returns {string} Summary HTML
134
- */
135
- function generateSummary(stats, score) {
85
+ function prepareTemplateData(stats, score, architecture, impact, gitInfo, timestamp, violations, aiSummary) {
136
86
  const { totalViolations, errorCount, warningCount, filesWithIssues } = stats;
137
- const status = errorCount > 0 ? 'failed' : warningCount > 0 ? 'warning' : 'passed';
138
- const statusIcon = errorCount > 0 ? '❌' : warningCount > 0 ? '⚠️' : '✅';
139
- const statusText = errorCount > 0 ? 'Failed' : warningCount > 0 ? 'Passed with Warnings' : 'Passed';
140
-
141
- const scoreValue = score.score !== undefined ? score.score : 'N/A';
142
- const grade = score.grade || 'N/A';
143
-
144
- return `
145
- <section class="summary">
146
- <div class="summary-header">
147
- <div class="status-badge status-${status}">
148
- <span class="status-icon">${statusIcon}</span>
149
- <span class="status-text">${statusText}</span>
150
- </div>
151
- ${scoreValue !== 'N/A' ? `
152
- <div class="score-display">
153
- <div class="score-value">${scoreValue}</div>
154
- <div class="score-grade">${grade}</div>
155
- </div>
156
- ` : ''}
157
- </div>
158
-
159
- <div class="summary-stats">
160
- <div class="stat-card">
161
- <div class="stat-value">${totalViolations}</div>
162
- <div class="stat-label">Total Violations</div>
163
- </div>
164
- <div class="stat-card stat-error">
165
- <div class="stat-value">${errorCount}</div>
166
- <div class="stat-label">Errors</div>
167
- </div>
168
- <div class="stat-card stat-warning">
169
- <div class="stat-value">${warningCount}</div>
170
- <div class="stat-label">Warnings</div>
171
- </div>
172
- <div class="stat-card">
173
- <div class="stat-value">${filesWithIssues}</div>
174
- <div class="stat-label">Files with Issues</div>
175
- </div>
176
- </div>
177
- </section>`;
178
- }
179
87
 
180
- /**
181
- * Generate violations by file section
182
- * @param {Array} violations - Violations array
183
- * @param {Object} fileGroups - Grouped by file
184
- * @returns {string} By file HTML
185
- */
186
- function generateByFileSection(violations, fileGroups) {
187
- if (violations.length === 0) {
188
- return `
189
- <section class="section">
190
- <h2>✅ Great Job!</h2>
191
- <p class="no-violations">No coding standard violations found.</p>
192
- </section>`;
193
- }
194
-
195
- const sortedFiles = Object.entries(fileGroups)
196
- .sort((a, b) => b[1].length - a[1].length);
197
-
198
- let html = `
199
- <section class="section">
200
- <h2>📁 Violations by File</h2>
201
- <div class="controls">
202
- <input type="text" id="fileSearch" class="search-box" placeholder="Search files...">
203
- <select id="fileFilter" class="filter-select">
204
- <option value="all">All Severities</option>
205
- <option value="error">Errors Only</option>
206
- <option value="warning">Warnings Only</option>
207
- </select>
208
- </div>
209
- <table class="violations-table" id="fileTable">
210
- <thead>
211
- <tr>
212
- <th onclick="sortTable('fileTable', 0)">File <span class="sort-icon">↕</span></th>
213
- <th onclick="sortTable('fileTable', 1)">Errors <span class="sort-icon">↕</span></th>
214
- <th onclick="sortTable('fileTable', 2)">Warnings <span class="sort-icon">↕</span></th>
215
- <th onclick="sortTable('fileTable', 3)">Total <span class="sort-icon">↕</span></th>
216
- <th>Details</th>
217
- </tr>
218
- </thead>
219
- <tbody>`;
220
-
221
- for (const [file, fileViolations] of sortedFiles) {
222
- const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
223
- const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
224
- const total = fileViolations.length;
225
- const fileId = `file-${Buffer.from(file).toString('base64').replace(/=/g, '')}`;
226
-
227
- html += `
228
- <tr class="file-row" data-errors="${fileErrors}" data-warnings="${fileWarnings}">
229
- <td class="file-path"><code>${escapeHTML(file)}</code></td>
230
- <td class="count-cell ${fileErrors > 0 ? 'has-errors' : ''}">${fileErrors}</td>
231
- <td class="count-cell ${fileWarnings > 0 ? 'has-warnings' : ''}">${fileWarnings}</td>
232
- <td class="count-cell">${total}</td>
233
- <td>
234
- <button class="details-btn" onclick="toggleDetails('${fileId}')">
235
- Show Details <span class="arrow">▼</span>
236
- </button>
237
- </td>
238
- </tr>
239
- <tr class="details-row" id="${fileId}" style="display: none;">
240
- <td colspan="5">
241
- <div class="details-content">
242
- ${generateFileDetails(fileViolations)}
243
- </div>
244
- </td>
245
- </tr>`;
88
+ // Quality data
89
+ const qualityScore = score.score ?? 0;
90
+ const qualityGrade = score.grade ?? 'F';
91
+ const qualityColor = qualityScore >= 80 ? 'text-success' : qualityScore >= 60 ? 'text-warning' : 'text-danger';
92
+ const qualityBarClass = qualityScore >= 80 ? 'excellent' : qualityScore >= 60 ? 'good' : 'poor';
93
+ const qualityBadgeClass = qualityScore >= 80 ? 'bg-success' : qualityScore >= 60 ? 'bg-warning' : 'bg-danger';
94
+ // Grade emoji and description based on score
95
+ let gradeEmoji, gradeDesc;
96
+ if (qualityScore >= 90) {
97
+ gradeEmoji = '🏆'; gradeDesc = 'Excellent';
98
+ } else if (qualityScore >= 80) {
99
+ gradeEmoji = '✨'; gradeDesc = 'Good';
100
+ } else if (qualityScore >= 70) {
101
+ gradeEmoji = '👍'; gradeDesc = 'Fair';
102
+ } else if (qualityScore >= 60) {
103
+ gradeEmoji = '⚡'; gradeDesc = 'Needs Work';
104
+ } else {
105
+ gradeEmoji = '🔧'; gradeDesc = 'Needs Improvement';
246
106
  }
247
107
 
248
- html += `
249
- </tbody>
250
- </table>
251
- </section>`;
252
-
253
- return html;
254
- }
255
-
256
- /**
257
- * Generate file details
258
- * @param {Array} violations - Violations for a file
259
- * @returns {string} Details HTML
260
- */
261
- function generateFileDetails(violations) {
262
- let html = '<ul class="violation-list">';
263
-
264
- for (const v of violations) {
265
- const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning';
266
- const severityIcon = v.severity === 'error' ? '🔴' : '🟡';
267
-
268
- html += `
269
- <li class="violation-item">
270
- <span class="${severityClass}">${severityIcon} ${v.severity.toUpperCase()}</span>
271
- <span class="violation-line">Line ${v.line}</span>
272
- <span class="violation-rule">[${escapeHTML(v.rule)}]</span>
273
- <span class="violation-message">${escapeHTML(v.message)}</span>
274
- </li>`;
275
- }
276
-
277
- html += '</ul>';
278
- return html;
279
- }
280
-
281
- /**
282
- * Generate violations by rule section
283
- * @param {Array} violations - Violations array
284
- * @param {Object} ruleGroups - Grouped by rule
285
- * @returns {string} By rule HTML
286
- */
287
- function generateByRuleSection(violations, ruleGroups) {
288
- if (violations.length === 0) {
289
- return '';
108
+ // Architecture data
109
+ const archPattern = architecture?.pattern?.toUpperCase() || 'UNKNOWN';
110
+ const archHealth = architecture?.healthScore ?? 0;
111
+ const archConfidence = architecture?.confidence ?? 0;
112
+ const archViolations = architecture?.violations?.length ?? 0;
113
+ const archColor = archHealth >= 80 ? 'text-success' : archHealth >= 60 ? 'text-warning' : 'text-danger';
114
+ const archBarClass = archHealth >= 80 ? 'bg-success' : archHealth >= 60 ? 'bg-warning' : 'bg-danger';
115
+
116
+ // Impact data
117
+ const impactScore = impact?.score ?? 0;
118
+ const impactSeverity = impact?.severity || 'LOW';
119
+ const affectedApis = impact?.endpoints?.length ?? 0;
120
+ const affectedTables = impact?.tables?.length ?? 0;
121
+ const impactIconClass = impactSeverity === 'HIGH' ? 'bg-danger' : impactSeverity === 'MEDIUM' ? 'bg-warning' : 'bg-success';
122
+ const impactBadgeClass = impactSeverity === 'HIGH' ? 'bg-danger' : impactSeverity === 'MEDIUM' ? 'bg-warning' : 'bg-success';
123
+ const impactBarClass = impactSeverity === 'HIGH' ? 'bg-danger' : impactSeverity === 'MEDIUM' ? 'bg-warning' : 'bg-success';
124
+
125
+ // Endpoints
126
+ const endpoints = (impact?.endpoints || []).slice(0, 20).map(ep => ({
127
+ method: ep.method || 'GET',
128
+ method_lower: (ep.method || 'GET').toLowerCase(),
129
+ path: ep.path || ep.endpoint || ''
130
+ }));
131
+
132
+ // Violations for table
133
+ const violationList = violations.slice(0, 100).map(v => ({
134
+ severity: v.severity,
135
+ severity_label: v.severity === 'error' ? 'Error' : 'Warning',
136
+ severity_icon: v.severity === 'error' ? '✕' : '⚠',
137
+ rule: v.rule || 'unknown',
138
+ file: shortenPath(v.file),
139
+ line: v.line || 1,
140
+ message: v.message || ''
141
+ }));
142
+
143
+ // Architecture violations
144
+ const archViolationList = (architecture?.violations || []).slice(0, 20).map(v => ({
145
+ type: v.type || 'violation',
146
+ source: shortenPath(v.source || v.file || ''),
147
+ target: shortenPath(v.target || ''),
148
+ description: v.message || v.description || ''
149
+ }));
150
+
151
+ // Extract owner/repo from repository_url (e.g., https://github.com/owner/repo -> owner/repo)
152
+ let repoFullName = process.env.GITHUB_REPOSITORY || 'Unknown';
153
+ if (repoFullName === 'Unknown' && gitInfo.repository_url) {
154
+ const match = gitInfo.repository_url.match(/github\.com\/([^\/]+\/[^\/]+)/);
155
+ if (match) {
156
+ repoFullName = match[1];
157
+ }
290
158
  }
291
159
 
292
- const sortedRules = Object.entries(ruleGroups)
293
- .sort((a, b) => b[1].length - a[1].length);
294
-
295
- let html = `
296
- <section class="section">
297
- <h2>📋 Violations by Rule</h2>
298
- <table class="violations-table" id="ruleTable">
299
- <thead>
300
- <tr>
301
- <th onclick="sortTable('ruleTable', 0)">Rule <span class="sort-icon">↕</span></th>
302
- <th onclick="sortTable('ruleTable', 1)">Errors <span class="sort-icon">↕</span></th>
303
- <th onclick="sortTable('ruleTable', 2)">Warnings <span class="sort-icon">↕</span></th>
304
- <th onclick="sortTable('ruleTable', 3)">Total <span class="sort-icon">↕</span></th>
305
- <th>Locations</th>
306
- </tr>
307
- </thead>
308
- <tbody>`;
309
-
310
- for (const [ruleId, ruleViolations] of sortedRules) {
311
- const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length;
312
- const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length;
313
- const total = ruleViolations.length;
314
- const ruleIdSafe = `rule-${Buffer.from(ruleId).toString('base64').replace(/=/g, '')}`;
315
-
316
- html += `
317
- <tr class="rule-row">
318
- <td class="rule-id"><code>${escapeHTML(ruleId)}</code></td>
319
- <td class="count-cell ${ruleErrors > 0 ? 'has-errors' : ''}">${ruleErrors}</td>
320
- <td class="count-cell ${ruleWarnings > 0 ? 'has-warnings' : ''}">${ruleWarnings}</td>
321
- <td class="count-cell">${total}</td>
322
- <td>
323
- <button class="details-btn" onclick="toggleDetails('${ruleIdSafe}')">
324
- Show Locations <span class="arrow">▼</span>
325
- </button>
326
- </td>
327
- </tr>
328
- <tr class="details-row" id="${ruleIdSafe}" style="display: none;">
329
- <td colspan="5">
330
- <div class="details-content">
331
- ${generateRuleDetails(ruleViolations)}
332
- </div>
333
- </td>
334
- </tr>`;
160
+ // Get branch name, fallback if HEAD (detached state)
161
+ let branchName = gitInfo.branch || 'Unknown';
162
+ if (branchName === 'HEAD') {
163
+ branchName = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || 'main';
335
164
  }
336
165
 
337
- html += `
338
- </tbody>
339
- </table>
340
- </section>`;
341
-
342
- return html;
166
+ return {
167
+ // Git Info
168
+ repo_name: repoFullName,
169
+ branch: branchName,
170
+ commit_short: gitInfo.commit_hash ? gitInfo.commit_hash.substring(0, 7) : (process.env.GITHUB_SHA ? process.env.GITHUB_SHA.substring(0, 7) : 'N/A'),
171
+ commit_author: gitInfo.author_name || gitInfo.author || process.env.GITHUB_ACTOR || 'Unknown',
172
+
173
+ // Header
174
+ generated_date: new Date(timestamp).toLocaleString(),
175
+ total_files: filesWithIssues,
176
+ duration: 'N/A',
177
+
178
+ // Quality
179
+ quality_score: qualityScore,
180
+ quality_grade: qualityGrade,
181
+ quality_color: qualityColor,
182
+ quality_bar_class: qualityBarClass,
183
+ quality_badge_class: qualityBadgeClass,
184
+ grade_emoji: gradeEmoji,
185
+ grade_desc: gradeDesc,
186
+ error_count: errorCount,
187
+ warning_count: warningCount,
188
+
189
+ // Architecture
190
+ has_architecture: architecture !== null,
191
+ arch_pattern: archPattern,
192
+ arch_health: archHealth,
193
+ arch_confidence: archConfidence,
194
+ arch_violations: archViolations,
195
+ arch_color: archColor,
196
+ arch_bar_class: archBarClass,
197
+ has_arch_violations: archViolations > 0,
198
+ arch_violation_list: archViolationList,
199
+
200
+ // Impact
201
+ has_impact: impact !== null,
202
+ impact_score: impactScore,
203
+ impact_severity: impactSeverity,
204
+ affected_apis: affectedApis,
205
+ affected_tables: affectedTables,
206
+ impact_icon_class: impactIconClass,
207
+ impact_badge_class: impactBadgeClass,
208
+ impact_bar_class: impactBarClass,
209
+ has_endpoints: endpoints.length > 0,
210
+ endpoints: endpoints,
211
+
212
+ // Violations
213
+ has_violations: totalViolations > 0,
214
+ no_violations: totalViolations === 0,
215
+ total_violations: totalViolations,
216
+ violations: violationList,
217
+
218
+ // AI Summary
219
+ has_ai_summary: !!aiSummary,
220
+ ai_summary_html: aiSummary ? formatAISummaryToHTML(aiSummary) : '',
221
+
222
+ // Footer
223
+ version: getVersion(),
224
+ year: new Date().getFullYear()
225
+ };
343
226
  }
344
227
 
345
228
  /**
346
- * Generate rule details
347
- * @param {Array} violations - Violations for a rule
348
- * @returns {string} Details HTML
229
+ * Format AI summary text to HTML
230
+ * @param {string} summary - Raw AI summary text
231
+ * @returns {string} Formatted HTML
349
232
  */
350
- function generateRuleDetails(violations) {
351
- let html = '<ul class="location-list">';
352
-
353
- for (const v of violations) {
354
- const severityClass = v.severity === 'error' ? 'severity-error' : 'severity-warning';
355
- const severityIcon = v.severity === 'error' ? '🔴' : '🟡';
356
-
357
- html += `
358
- <li class="location-item">
359
- <span class="${severityClass}">${severityIcon}</span>
360
- <code class="location-path">${escapeHTML(v.file)}:${v.line}</code>
361
- <span class="location-message">${escapeHTML(v.message)}</span>
362
- </li>`;
233
+ function formatAISummaryToHTML(summary) {
234
+ if (!summary) return '';
235
+
236
+ let formatted = summary;
237
+ let verdictHtml = '';
238
+
239
+ // Extract verdict from first line
240
+ const lines = formatted.split('\n');
241
+ const firstLine = lines[0].trim();
242
+
243
+ if (firstLine.includes('REQUIRES FIXES') || firstLine.includes('🚫')) {
244
+ verdictHtml = '<div class="ai-verdict ai-verdict--danger"><span class="ai-verdict-icon">🚫</span><span class="ai-verdict-text">Requires Fixes</span><span class="ai-verdict-desc">Critical issues must be resolved before merging</span></div>';
245
+ formatted = lines.slice(1).join('\n').trim();
246
+ } else if (firstLine.includes('NEEDS ATTENTION') || firstLine.includes('⚠️')) {
247
+ verdictHtml = '<div class="ai-verdict ai-verdict--warning"><span class="ai-verdict-icon">⚠️</span><span class="ai-verdict-text">Needs Attention</span><span class="ai-verdict-desc">Review recommended but not blocking</span></div>';
248
+ formatted = lines.slice(1).join('\n').trim();
249
+ } else if (firstLine.includes('READY TO MERGE') || firstLine.includes('✅')) {
250
+ verdictHtml = '<div class="ai-verdict ai-verdict--success"><span class="ai-verdict-icon">✅</span><span class="ai-verdict-text">Ready to Merge</span><span class="ai-verdict-desc">Code quality meets standards</span></div>';
251
+ formatted = lines.slice(1).join('\n').trim();
363
252
  }
364
253
 
365
- html += '</ul>';
366
- return html;
367
- }
368
-
369
- /**
370
- * Generate footer
371
- * @param {string} timestamp - Report timestamp
372
- * @returns {string} Footer HTML
373
- */
374
- function generateFooter(timestamp) {
375
- return `
376
- <footer class="footer">
377
- <p>
378
- Generated by <a href="https://github.com/sun-asterisk/engineer-excellence" target="_blank">SunLint</a>
379
- on ${new Date(timestamp).toLocaleString()}
380
- </p>
381
- </footer>`;
382
- }
383
-
384
- /**
385
- * Embed CSS styles
386
- * @returns {string} Style tag
387
- */
388
- function embedCSS() {
389
- return `<style>
390
- * { margin: 0; padding: 0; box-sizing: border-box; }
391
-
392
- body {
393
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
394
- line-height: 1.6;
395
- color: #333;
396
- background: #f5f5f5;
397
- padding: 20px;
398
- }
399
-
400
- .container {
401
- max-width: 1400px;
402
- margin: 0 auto;
403
- background: white;
404
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
405
- border-radius: 8px;
406
- overflow: hidden;
407
- }
408
-
409
- .header {
410
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
411
- color: white;
412
- padding: 30px;
413
- }
414
-
415
- .header-content h1 {
416
- font-size: 28px;
417
- margin-bottom: 20px;
418
- display: flex;
419
- align-items: center;
420
- gap: 10px;
421
- }
254
+ // Highlight rule IDs (C001, C019, etc.)
255
+ formatted = formatted.replace(/\b(C\d{3})\b/g, '<span class="rule-tag">$1</span>');
422
256
 
423
- .logo { font-size: 32px; }
257
+ // Highlight severity words
258
+ formatted = formatted.replace(/\b(critical|high|error|errors)\b/gi, '<span class="highlight-error">$1</span>');
259
+ formatted = formatted.replace(/\b(warning|warnings|medium)\b/gi, '<span class="highlight-warning">$1</span>');
424
260
 
425
- .header-meta {
426
- display: grid;
427
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
428
- gap: 15px;
429
- margin-top: 20px;
430
- }
431
-
432
- .meta-item {
433
- background: rgba(255,255,255,0.1);
434
- padding: 10px 15px;
435
- border-radius: 5px;
436
- }
437
-
438
- .meta-label {
439
- display: block;
440
- font-size: 12px;
441
- opacity: 0.8;
442
- margin-bottom: 5px;
443
- }
261
+ // Split into sentences
262
+ const sentences = formatted.split(/(?<=[.!?])\s+/).filter(s => s.trim());
444
263
 
445
- .meta-value {
446
- display: block;
447
- font-size: 14px;
448
- font-weight: 600;
449
- }
450
-
451
- .summary {
452
- padding: 30px;
453
- background: #f9fafb;
454
- border-bottom: 1px solid #e5e7eb;
455
- }
456
-
457
- .summary-header {
458
- display: flex;
459
- justify-content: space-between;
460
- align-items: center;
461
- margin-bottom: 30px;
462
- }
463
-
464
- .status-badge {
465
- display: inline-flex;
466
- align-items: center;
467
- gap: 10px;
468
- padding: 12px 24px;
469
- border-radius: 8px;
470
- font-size: 20px;
471
- font-weight: 600;
472
- }
473
-
474
- .status-passed { background: #d1fae5; color: #065f46; }
475
- .status-warning { background: #fef3c7; color: #92400e; }
476
- .status-failed { background: #fee2e2; color: #991b1b; }
477
-
478
- .score-display {
479
- text-align: center;
480
- }
481
-
482
- .score-value {
483
- font-size: 48px;
484
- font-weight: bold;
485
- color: #667eea;
486
- }
487
-
488
- .score-grade {
489
- font-size: 24px;
490
- color: #666;
491
- margin-top: -5px;
492
- }
493
-
494
- .summary-stats {
495
- display: grid;
496
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
497
- gap: 20px;
498
- }
499
-
500
- .stat-card {
501
- background: white;
502
- padding: 20px;
503
- border-radius: 8px;
504
- text-align: center;
505
- border: 2px solid #e5e7eb;
506
- }
507
-
508
- .stat-card.stat-error { border-color: #fca5a5; }
509
- .stat-card.stat-warning { border-color: #fcd34d; }
510
-
511
- .stat-value {
512
- font-size: 36px;
513
- font-weight: bold;
514
- color: #667eea;
515
- margin-bottom: 5px;
516
- }
264
+ let contentHtml = '';
265
+ if (sentences.length > 1) {
266
+ const mainIssues = [];
267
+ const recommendations = [];
517
268
 
518
- .stat-card.stat-error .stat-value { color: #dc2626; }
519
- .stat-card.stat-warning .stat-value { color: #f59e0b; }
520
-
521
- .stat-label {
522
- font-size: 14px;
523
- color: #666;
524
- }
525
-
526
- .section {
527
- padding: 30px;
528
- border-bottom: 1px solid #e5e7eb;
529
- }
530
-
531
- .section h2 {
532
- font-size: 22px;
533
- margin-bottom: 20px;
534
- color: #1f2937;
535
- }
536
-
537
- .controls {
538
- display: flex;
539
- gap: 15px;
540
- margin-bottom: 20px;
541
- }
542
-
543
- .search-box, .filter-select {
544
- padding: 10px 15px;
545
- border: 2px solid #e5e7eb;
546
- border-radius: 6px;
547
- font-size: 14px;
548
- }
549
-
550
- .search-box {
551
- flex: 1;
552
- max-width: 400px;
553
- }
554
-
555
- .filter-select {
556
- min-width: 150px;
557
- }
558
-
559
- .violations-table {
560
- width: 100%;
561
- border-collapse: collapse;
562
- background: white;
563
- border: 1px solid #e5e7eb;
564
- border-radius: 8px;
565
- overflow: hidden;
566
- }
567
-
568
- .violations-table th {
569
- background: #f9fafb;
570
- padding: 12px;
571
- text-align: left;
572
- font-weight: 600;
573
- border-bottom: 2px solid #e5e7eb;
574
- cursor: pointer;
575
- user-select: none;
576
- }
577
-
578
- .violations-table th:hover { background: #f3f4f6; }
579
-
580
- .sort-icon {
581
- font-size: 10px;
582
- opacity: 0.5;
583
- margin-left: 5px;
584
- }
585
-
586
- .violations-table td {
587
- padding: 12px;
588
- border-bottom: 1px solid #e5e7eb;
589
- }
590
-
591
- .file-path, .rule-id {
592
- font-family: 'Courier New', monospace;
593
- font-size: 13px;
594
- }
595
-
596
- .count-cell {
597
- text-align: center;
598
- font-weight: 600;
599
- }
600
-
601
- .count-cell.has-errors { color: #dc2626; }
602
- .count-cell.has-warnings { color: #f59e0b; }
603
-
604
- .details-btn {
605
- background: #667eea;
606
- color: white;
607
- border: none;
608
- padding: 6px 12px;
609
- border-radius: 4px;
610
- cursor: pointer;
611
- font-size: 13px;
612
- display: inline-flex;
613
- align-items: center;
614
- gap: 5px;
615
- }
616
-
617
- .details-btn:hover { background: #5568d3; }
618
-
619
- .arrow {
620
- font-size: 10px;
621
- transition: transform 0.2s;
622
- }
623
-
624
- .details-row td {
625
- background: #f9fafb;
626
- padding: 0;
627
- }
628
-
629
- .details-content {
630
- padding: 20px;
631
- }
632
-
633
- .violation-list, .location-list {
634
- list-style: none;
635
- }
636
-
637
- .violation-item, .location-item {
638
- padding: 10px;
639
- margin-bottom: 8px;
640
- background: white;
641
- border-left: 3px solid #e5e7eb;
642
- border-radius: 4px;
643
- display: flex;
644
- gap: 10px;
645
- align-items: center;
646
- }
647
-
648
- .severity-error { color: #dc2626; font-weight: 600; }
649
- .severity-warning { color: #f59e0b; font-weight: 600; }
650
-
651
- .violation-line {
652
- background: #e5e7eb;
653
- padding: 2px 8px;
654
- border-radius: 3px;
655
- font-size: 12px;
656
- font-family: monospace;
269
+ for (const sentence of sentences) {
270
+ const lower = sentence.toLowerCase();
271
+ if (lower.includes('prioritize') || lower.includes('focus') || lower.includes('recommend') || lower.includes('should')) {
272
+ recommendations.push(sentence.trim());
273
+ } else {
274
+ mainIssues.push(sentence.trim());
275
+ }
657
276
  }
658
277
 
659
- .violation-rule {
660
- color: #667eea;
661
- font-weight: 600;
662
- font-size: 12px;
278
+ if (mainIssues.length > 0) {
279
+ contentHtml += '<div class="issue-section">';
280
+ contentHtml += '<div class="section-title">🔍 Issues Found</div>';
281
+ contentHtml += '<ul>';
282
+ for (const issue of mainIssues) {
283
+ contentHtml += `<li>${issue}</li>`;
284
+ }
285
+ contentHtml += '</ul></div>';
663
286
  }
664
287
 
665
- .violation-message, .location-message {
666
- flex: 1;
667
- color: #666;
668
- font-size: 14px;
288
+ if (recommendations.length > 0) {
289
+ contentHtml += '<div class="recommendation-section">';
290
+ contentHtml += '<div class="section-title">💡 Recommendations</div>';
291
+ contentHtml += '<ul>';
292
+ for (const rec of recommendations) {
293
+ contentHtml += `<li>${rec}</li>`;
294
+ }
295
+ contentHtml += '</ul></div>';
669
296
  }
297
+ } else if (formatted) {
298
+ contentHtml = `<p>${formatted}</p>`;
299
+ }
670
300
 
671
- .location-path {
672
- font-family: 'Courier New', monospace;
673
- background: #f3f4f6;
674
- padding: 2px 8px;
675
- border-radius: 3px;
676
- font-size: 12px;
677
- }
301
+ return verdictHtml + contentHtml;
302
+ }
678
303
 
679
- .no-violations {
680
- text-align: center;
681
- padding: 40px;
682
- font-size: 18px;
683
- color: #10b981;
684
- }
304
+ /**
305
+ * Render template with data
306
+ * Simple template engine supporting {{var}}, {{#if}}, {{#each}}
307
+ */
308
+ function renderTemplate(template, data) {
309
+ let result = template;
310
+
311
+ // Handle {{#each items}}...{{/each}}
312
+ result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, content) => {
313
+ const items = data[key];
314
+ if (!Array.isArray(items) || items.length === 0) {
315
+ return '';
316
+ }
317
+ return items.map(item => {
318
+ let itemContent = content;
319
+ // Replace item properties
320
+ for (const [propKey, propValue] of Object.entries(item)) {
321
+ const regex = new RegExp(`\\{\\{${propKey}\\}\\}`, 'g');
322
+ itemContent = itemContent.replace(regex, escapeHTML(String(propValue ?? '')));
323
+ }
324
+ return itemContent;
325
+ }).join('');
326
+ });
685
327
 
686
- .footer {
687
- padding: 20px;
688
- text-align: center;
689
- color: #666;
690
- font-size: 14px;
691
- background: #f9fafb;
692
- }
328
+ // Handle {{#if condition}}...{{/if}}
329
+ result = result.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, key, content) => {
330
+ return data[key] ? content : '';
331
+ });
693
332
 
694
- .footer a {
695
- color: #667eea;
696
- text-decoration: none;
333
+ // Handle simple {{variable}} replacements
334
+ result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => {
335
+ const value = data[key];
336
+ if (value === undefined || value === null) {
337
+ return '';
697
338
  }
698
-
699
- .footer a:hover { text-decoration: underline; }
700
-
701
- @media print {
702
- body { background: white; padding: 0; }
703
- .container { box-shadow: none; }
704
- .details-btn { display: none; }
705
- .details-row { display: table-row !important; }
339
+ // Don't escape class names, style values, or pre-formatted HTML
340
+ if (key.includes('class') || key.includes('color') || key.includes('bar') || key.endsWith('_html')) {
341
+ return String(value);
706
342
  }
343
+ return escapeHTML(String(value));
344
+ });
707
345
 
708
- @media (max-width: 768px) {
709
- .header-meta { grid-template-columns: 1fr; }
710
- .summary-stats { grid-template-columns: 1fr; }
711
- .controls { flex-direction: column; }
712
- .search-box { max-width: 100%; }
713
- }
714
- </style>`;
346
+ return result;
715
347
  }
716
348
 
717
349
  /**
718
- * Embed JavaScript
719
- * @returns {string} Script tag
350
+ * Shorten file path for display
720
351
  */
721
- function embedJavaScript() {
722
- return `<script>
723
- // Toggle details row
724
- function toggleDetails(id) {
725
- const row = document.getElementById(id);
726
- const btn = event.target.closest('.details-btn');
727
- const arrow = btn.querySelector('.arrow');
728
-
729
- if (row.style.display === 'none') {
730
- row.style.display = 'table-row';
731
- arrow.style.transform = 'rotate(180deg)';
732
- btn.innerHTML = 'Hide Details <span class="arrow" style="transform: rotate(180deg);">▼</span>';
733
- } else {
734
- row.style.display = 'none';
735
- arrow.style.transform = 'rotate(0deg)';
736
- btn.innerHTML = 'Show Details <span class="arrow">▼</span>';
737
- }
738
- }
739
-
740
- // Sort table
741
- function sortTable(tableId, columnIndex) {
742
- const table = document.getElementById(tableId);
743
- const tbody = table.querySelector('tbody');
744
- const rows = Array.from(tbody.querySelectorAll('tr')).filter(r => !r.classList.contains('details-row'));
745
-
746
- const isNumeric = columnIndex > 0;
747
- rows.sort((a, b) => {
748
- const aVal = a.cells[columnIndex].textContent.trim();
749
- const bVal = b.cells[columnIndex].textContent.trim();
750
-
751
- if (isNumeric) {
752
- return parseInt(bVal) - parseInt(aVal);
753
- }
754
- return aVal.localeCompare(bVal);
755
- });
756
-
757
- rows.forEach(row => {
758
- const detailsRow = row.nextElementSibling;
759
- tbody.appendChild(row);
760
- if (detailsRow && detailsRow.classList.contains('details-row')) {
761
- tbody.appendChild(detailsRow);
762
- }
763
- });
764
- }
765
-
766
- // File search
767
- const fileSearch = document.getElementById('fileSearch');
768
- if (fileSearch) {
769
- fileSearch.addEventListener('input', function() {
770
- const searchTerm = this.value.toLowerCase();
771
- const rows = document.querySelectorAll('#fileTable tbody tr.file-row');
772
-
773
- rows.forEach(row => {
774
- const file = row.querySelector('.file-path').textContent.toLowerCase();
775
- const detailsRow = row.nextElementSibling;
776
-
777
- if (file.includes(searchTerm)) {
778
- row.style.display = '';
779
- if (detailsRow && detailsRow.style.display !== 'none') {
780
- detailsRow.style.display = '';
781
- }
782
- } else {
783
- row.style.display = 'none';
784
- if (detailsRow) {
785
- detailsRow.style.display = 'none';
786
- }
787
- }
788
- });
789
- });
790
- }
352
+ function shortenPath(filePath) {
353
+ if (!filePath) return '';
354
+ const parts = filePath.split('/');
355
+ if (parts.length <= 3) return filePath;
356
+ return '.../' + parts.slice(-3).join('/');
357
+ }
791
358
 
792
- // File filter
793
- const fileFilter = document.getElementById('fileFilter');
794
- if (fileFilter) {
795
- fileFilter.addEventListener('change', function() {
796
- const filter = this.value;
797
- const rows = document.querySelectorAll('#fileTable tbody tr.file-row');
798
-
799
- rows.forEach(row => {
800
- const errors = parseInt(row.dataset.errors);
801
- const warnings = parseInt(row.dataset.warnings);
802
- const detailsRow = row.nextElementSibling;
803
-
804
- let show = true;
805
- if (filter === 'error' && errors === 0) show = false;
806
- if (filter === 'warning' && warnings === 0) show = false;
807
-
808
- if (show) {
809
- row.style.display = '';
810
- } else {
811
- row.style.display = 'none';
812
- if (detailsRow) {
813
- detailsRow.style.display = 'none';
814
- }
815
- }
816
- });
817
- });
818
- }
819
- </script>`;
359
+ /**
360
+ * Get package version
361
+ */
362
+ function getVersion() {
363
+ try {
364
+ const packagePath = path.join(__dirname, '..', 'package.json');
365
+ const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
366
+ return pkg.version || '1.0.0';
367
+ } catch {
368
+ return '1.0.0';
369
+ }
820
370
  }
821
371
 
822
372
  /**
823
373
  * Escape HTML special characters
824
- * @param {string} text - Text to escape
825
- * @returns {string} Escaped text
826
374
  */
827
375
  function escapeHTML(text) {
828
376
  if (!text) return '';
@@ -834,6 +382,53 @@ function escapeHTML(text) {
834
382
  .replace(/'/g, '&#039;');
835
383
  }
836
384
 
385
+ /**
386
+ * Fallback HTML generator if template fails
387
+ */
388
+ function generateFallbackHTML(violations, metadata) {
389
+ const { score = {}, architecture, impact } = metadata;
390
+ const errorCount = violations.filter(v => v.severity === 'error').length;
391
+ const warningCount = violations.filter(v => v.severity === 'warning').length;
392
+
393
+ return `<!DOCTYPE html>
394
+ <html>
395
+ <head>
396
+ <meta charset="UTF-8">
397
+ <title>SunLint Report</title>
398
+ <style>
399
+ body { font-family: system-ui; padding: 2rem; background: #1e293b; color: #f1f5f9; }
400
+ .card { background: #334155; padding: 1.5rem; border-radius: 0.5rem; margin: 1rem 0; }
401
+ .error { color: #fca5a5; }
402
+ .warning { color: #fcd34d; }
403
+ h1 { color: #10b981; }
404
+ </style>
405
+ </head>
406
+ <body>
407
+ <h1>SunLint Report</h1>
408
+ <div class="card">
409
+ <h2>Code Quality</h2>
410
+ <p>Score: <strong>${score.score ?? 'N/A'}</strong>/100 (${score.grade ?? 'N/A'})</p>
411
+ <p><span class="error">${errorCount} errors</span> · <span class="warning">${warningCount} warnings</span></p>
412
+ </div>
413
+ ${architecture ? `
414
+ <div class="card">
415
+ <h2>Architecture</h2>
416
+ <p>Pattern: <strong>${architecture.pattern?.toUpperCase() || 'UNKNOWN'}</strong></p>
417
+ <p>Health: ${architecture.healthScore ?? 0}/100</p>
418
+ </div>
419
+ ` : ''}
420
+ ${impact ? `
421
+ <div class="card">
422
+ <h2>Impact</h2>
423
+ <p>Severity: <strong>${impact.severity || 'LOW'}</strong></p>
424
+ <p>Score: ${impact.score ?? 0}/100</p>
425
+ </div>
426
+ ` : ''}
427
+ <p style="margin-top: 2rem; color: #94a3b8;">Generated by SunLint</p>
428
+ </body>
429
+ </html>`;
430
+ }
431
+
837
432
  module.exports = {
838
433
  generateHTMLReport
839
434
  };