UNPKG

@sun-asterisk/sunlint

Version:

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

1,003 lines (866 loc) 35.3 kB
/** * Output Service */ const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); const ScoringService = require('./scoring-service'); const SummaryReportService = require('./summary-report-service'); const UploadService = require('./upload-service'); class OutputService { constructor() { this.scoringService = new ScoringService(); this.summaryReportService = new SummaryReportService(); this.uploadService = new UploadService(); // Load version from package.json this.version = this._loadVersion(); } /** * Load version from package.json * @returns {string} Package version * @private */ _loadVersion() { try { const packageJsonPath = path.join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version || '1.3.21'; } catch (error) { return '1.3.21'; // Fallback version } } async outputResults(results, options, metadata = {}) { // Handle GitHub annotation setup const githubAnnotateConfig = this._prepareGitHubAnnotation(options); // Generate report based on format (override format to json if github-annotate is enabled) const effectiveFormat = githubAnnotateConfig.shouldAnnotate ? 'json' : options.format; const report = this.generateReport(results, metadata, { ...options, format: effectiveFormat }); // Console output // Skip console output when using --github-annotate to avoid JSON clutter if (!options.quiet && !githubAnnotateConfig.shouldAnnotate) { console.log(report.formatted); } // Determine output file (temp or user-specified) let outputFile = options.output; let shouldCleanupTempFile = false; if (githubAnnotateConfig.shouldAnnotate && !outputFile) { // Create temp file for GitHub annotation outputFile = githubAnnotateConfig.tempFile; shouldCleanupTempFile = true; if (options.verbose) { console.log(chalk.gray(`ℹ️ Created temporary report file for GitHub annotation: ${outputFile}`)); } } // File output if (outputFile) { const outputData = effectiveFormat === 'json' ? report.raw : report.formatted; const content = typeof outputData === 'string' ? outputData : JSON.stringify(outputData, null, 2); try { fs.writeFileSync(outputFile, content); if (!shouldCleanupTempFile) { console.log(chalk.green(`📄 Report saved to: ${outputFile}`)); } } catch (error) { console.error(chalk.red(`❌ Failed to write report file: ${error.message}`)); if (shouldCleanupTempFile) { this._cleanupTempFile(outputFile); } throw error; } } // GitHub annotation if (githubAnnotateConfig.shouldAnnotate) { await this._handleGitHubAnnotation( githubAnnotateConfig, outputFile, shouldCleanupTempFile ); } // Summary report output (new feature for CI/CD) if (options.outputSummary) { const summaryReport = this.generateAndSaveSummaryReport( report.violations, results, options, metadata ); console.log(chalk.green(`📊 Summary report saved to: ${options.outputSummary}`)); // Display summary in console if (!options.quiet) { console.log(this.summaryReportService.formatTextSummary(summaryReport)); } // Upload report if upload URL is provided if (options.uploadReport) { await this.handleUploadReport(options.outputSummary, options.uploadReport, options); } } // Summary (skip for JSON format) if (!options.quiet && options.format !== 'json') { console.log(report.summary); } // Output architecture results if available if (results.architecture && !options.quiet) { this.outputArchitectureResults(results.architecture, options); } } /** * Output architecture analysis results * @param {Object} archResults - Architecture analysis results * @param {Object} options - Output options */ outputArchitectureResults(archResults, options) { if (!archResults || !archResults.summary) { return; } const summary = archResults.summary; console.log(chalk.blue('\n🏛️ Architecture Analysis:')); console.log('━'.repeat(50)); // Primary pattern const confidence = Math.round(summary.primaryConfidence * 100); console.log(`• Pattern: ${chalk.cyan(summary.primaryPattern)} (${confidence}% confidence)`); // Secondary patterns if (summary.secondaryPatterns && summary.secondaryPatterns.length > 0) { const secondary = summary.secondaryPatterns .map(p => `${p.pattern} (${Math.round(p.confidence * 100)}%)`) .join(', '); console.log(`• Secondary: ${chalk.gray(secondary)}`); } // Health score const healthScore = Math.round(summary.healthScore); const healthColor = healthScore >= 80 ? chalk.green : healthScore >= 60 ? chalk.yellow : chalk.red; console.log(`• Health Score: ${healthColor(healthScore + '/100')}`); // Violations if (summary.violationCount > 0) { console.log(`• Violations: ${chalk.red(summary.violationCount)}`); // Show first 5 violations if (archResults.violations && archResults.violations.length > 0) { console.log(chalk.gray('\nTop Architecture Violations:')); archResults.violations.slice(0, 5).forEach((v, i) => { const severity = v.severity === 'error' ? chalk.red('✗') : chalk.yellow('⚠'); console.log(` ${severity} ${v.message}`); if (v.file) { console.log(chalk.gray(` → ${v.file}:${v.line || 1}`)); } }); if (archResults.violations.length > 5) { console.log(chalk.gray(` ... and ${archResults.violations.length - 5} more`)); } } } else { console.log(`• Violations: ${chalk.green('None')}`); } // Report file info if (archResults.markdownReport && options.archReport) { console.log(chalk.gray('\n📄 Full architecture report saved')); } console.log(); } generateAndSaveSummaryReport(violations, results, options, metadata) { const totalFiles = results.filesAnalyzed || results.summary?.totalFiles || results.totalFiles || results.fileCount || 0; // Calculate LOC let loc = 0; if (options.input) { const inputPaths = Array.isArray(options.input) ? options.input : [options.input]; for (const inputPath of inputPaths) { if (fs.existsSync(inputPath)) { const stat = fs.statSync(inputPath); if (stat.isDirectory()) { loc += this.scoringService.calculateDirectoryLOC(inputPath); } else { loc += this.scoringService.calculateLOC([inputPath]); } } } } // Count violations const errorCount = violations.filter(v => v.severity === 'error').length; const warningCount = violations.filter(v => v.severity === 'warning').length; // Get number of rules checked - use metadata first, then parse from options let rulesChecked = metadata.rulesChecked; if (!rulesChecked && options.rule) { rulesChecked = typeof options.rule === 'string' ? options.rule.split(',').filter(r => r.trim()).length : Array.isArray(options.rule) ? options.rule.length : 1; } rulesChecked = rulesChecked || 1; // Calculate score const scoringSummary = this.scoringService.generateScoringSummary({ errorCount, warningCount, rulesChecked, loc }); // Generate and save summary report const summaryReport = this.summaryReportService.saveSummaryReport( violations, scoringSummary, options.outputSummary, { cwd: process.cwd(), filesAnalyzed: totalFiles, duration: metadata.duration, version: metadata.version || this.version } ); return summaryReport; } generateReport(results, metadata, options = {}) { const allViolations = []; let totalFiles = results.filesAnalyzed || results.summary?.totalFiles || results.totalFiles || results.fileCount || 0; // Helper function to validate violation object const isValidViolation = (violation) => { if (!violation || typeof violation !== 'object') return false; // Skip config/metadata objects (have nested objects like semanticEngine, project, etc.) if (violation.semanticEngine || violation.project || violation._context) return false; // Must have ruleId as string const ruleId = violation.ruleId || violation.rule; if (!ruleId || typeof ruleId !== 'string') return false; return true; }; // Collect all violations - handle both file-based and rule-based results if (results.results) { results.results.forEach(result => { if (result.violations) { // Handle rule-based format (MultiRuleRunner) if (result.ruleId) { result.violations.forEach(violation => { if (isValidViolation(violation)) { allViolations.push(violation); // violation already has file path } }); } // Handle file-based format (legacy) else { result.violations.forEach(violation => { if (isValidViolation(violation)) { allViolations.push({ ...violation, file: result.filePath || result.file // Use filePath first, then file }); } }); } } // Handle ESLint format (messages array) if (result.messages) { result.messages.forEach(message => { const violation = { file: result.filePath || message.file, ruleId: message.ruleId, severity: message.severity === 2 ? 'error' : 'warning', message: message.message, line: message.line, column: message.column, source: message.source || 'eslint' }; if (isValidViolation(violation)) { allViolations.push(violation); } }); } }); } // Generate output based on format let formatted; let raw; if (options.format === 'json') { // ESLint-compatible JSON format raw = this.generateJsonFormat(results, allViolations, options); formatted = JSON.stringify(raw, null, 2); } else { // Default text format formatted = this.formatViolations(allViolations); raw = { violations: allViolations, filesAnalyzed: totalFiles, metadata }; } const summary = this.generateSummary(allViolations, totalFiles, metadata); return { formatted, summary, raw, violations: allViolations // Add violations for summary report }; } formatViolations(violations) { if (violations.length === 0) { return ''; // Summary already shown by orchestrator } let output = ''; const fileGroups = {}; // Group violations by file violations.forEach(violation => { let file = violation.file || violation.filePath || 'unknown'; // Convert absolute path to relative path for better display if (file !== 'unknown' && path.isAbsolute(file)) { const cwd = process.cwd(); if (file.startsWith(cwd)) { file = path.relative(cwd, file); } } if (!fileGroups[file]) { fileGroups[file] = []; } fileGroups[file].push(violation); }); // Sort file paths alphabetically for consistent output const sortedFiles = Object.keys(fileGroups).sort(); // Format each file's violations (ESLint-compatible format) sortedFiles.forEach((file, index) => { // Sort violations by line number within each file const sortedViolations = fileGroups[file].sort((a, b) => { const lineA = a.location?.start?.line || a.line || 1; const lineB = b.location?.start?.line || b.line || 1; if (lineA !== lineB) return lineA - lineB; return (a.location?.start?.column || a.column || 1) - (b.location?.start?.column || b.column || 1); }); // Add blank line before file header (except for the first file) if (index > 0) { output += '\n'; } output += `\n${chalk.underline(path.resolve(file))}\n`; sortedViolations.forEach(violation => { // Support both location.start.line (new format) and line (legacy format) const line = (violation.location?.start?.line || violation.line || 1).toString(); const column = (violation.location?.start?.column || violation.column || 1).toString(); const severityText = violation.severity === 'error' ? 'error' : 'warning'; const severityColor = violation.severity === 'error' ? chalk.red : chalk.yellow; output += ` ${chalk.dim(`${line}:${column}`)} ${severityColor(severityText)} ${violation.message} ${chalk.gray(violation.ruleId)}\n`; }); }); // Add violation count (ESLint-compatible) const errorCount = violations.filter(v => v.severity === 'error').length; const warningCount = violations.filter(v => v.severity === 'warning').length; output += `\n${chalk.red('✖')} ${violations.length} problems `; output += `(${errorCount} errors, ${warningCount} warnings)\n`; return output; } generateSummary(violations, filesAnalyzed, metadata) { // Summary is now minimal - main info shown by orchestrator if (violations.length === 0) { return ''; // Clean output when no issues } const errorCount = violations.filter(v => v.severity === 'error').length; const warningCount = violations.filter(v => v.severity === 'warning').length; let summary = '\n'; if (errorCount > 0 || warningCount > 0) { summary += chalk.gray(' ') + chalk.dim(`${filesAnalyzed} files · `); if (errorCount > 0) { summary += chalk.red(`${errorCount} errors `); } if (warningCount > 0) { summary += chalk.yellow(`${warningCount} warnings`); } } return summary; } generateJsonFormat(results, allViolations, options = {}) { // ESLint-compatible JSON format const jsonResults = []; const fileGroups = {}; // Group violations by file allViolations.forEach(violation => { let file = violation.file || violation.filePath || 'unknown'; // Convert absolute path to relative path for better display if (file !== 'unknown' && path.isAbsolute(file)) { const cwd = process.cwd(); if (file.startsWith(cwd)) { file = path.relative(cwd, file); } } if (!fileGroups[file]) { fileGroups[file] = []; } fileGroups[file].push(violation); }); // Add files with violations Object.keys(fileGroups).forEach(filePath => { const messages = fileGroups[filePath].map(violation => ({ ruleId: violation.ruleId, severity: violation.severity === 'error' ? 2 : 1, // ESLint: 1=warning, 2=error message: violation.message, line: violation.line || 1, column: violation.column || 1, nodeType: violation.nodeType || null, messageId: violation.messageId || null, endLine: violation.endLine || null, endColumn: violation.endColumn || null })); jsonResults.push({ filePath: filePath, messages: messages, suppressedMessages: [], errorCount: messages.filter(m => m.severity === 2).length, warningCount: messages.filter(m => m.severity === 1).length, fatalErrorCount: 0, fixableErrorCount: 0, fixableWarningCount: 0, source: null }); }); // Add files without violations (if any were analyzed) if (results.results) { results.results.forEach(fileResult => { if (!fileGroups[fileResult.file] && fileResult.violations.length === 0) { jsonResults.push({ filePath: fileResult.file, messages: [], suppressedMessages: [], errorCount: 0, warningCount: 0, fatalErrorCount: 0, fixableErrorCount: 0, fixableWarningCount: 0, source: null }); } }); } return jsonResults; } /** * Handle uploading report to API endpoint */ async handleUploadReport(filePath, apiUrl, options = {}) { try { if (!filePath) { throw new Error('Summary report file path is required for upload'); } if (!apiUrl) { throw new Error('API URL is required for upload'); } // Check if curl is available if (!this.uploadService.checkCurlAvailability()) { console.warn(chalk.yellow('⚠️ curl is not available. Skipping report upload.')); return; } // Validate API endpoint if not in quiet mode if (!options.quiet) { console.log(chalk.blue('🔍 Checking API endpoint accessibility...')); const endpointCheck = await this.uploadService.validateApiEndpoint(apiUrl, { timeout: options.uploadTimeout || 10 }); if (!endpointCheck.accessible) { console.warn(chalk.yellow(`⚠️ API endpoint may not be accessible: ${endpointCheck.error}`)); console.warn(chalk.yellow('Proceeding with upload attempt...')); } } // Upload the report const uploadResult = await this.uploadService.uploadReportToApi(filePath, apiUrl, { timeout: options.uploadTimeout || 30, verbose: options.verbose, debug: options.debug }); if (uploadResult.success) { if (!options.quiet && uploadResult.response) { try { const responseData = JSON.parse(uploadResult.response); if (responseData.message) { console.log(chalk.blue(`💬 Server response: ${responseData.message}`)); } if (responseData.report_id) { console.log(chalk.blue(`📝 Report ID: ${responseData.report_id}`)); } if (responseData.repository) { console.log(chalk.blue(`🏠 Repository: ${responseData.repository}`)); } if (responseData.actor) { console.log(chalk.blue(`👤 Submitted by: ${responseData.actor}`)); } } catch (parseError) { // If response is not JSON, show raw response if verbose if (options.verbose) { console.log(chalk.gray(`📄 Response: ${uploadResult.response.substring(0, 200)}...`)); } } } } else { // Error already logged in upload-service.js if (options.verbose && uploadResult.errorContext) { console.warn('Upload error details:', uploadResult.errorContext); } } return uploadResult; } catch (error) { console.error(chalk.red('❌ Upload process failed:'), error.message); if (options.debug) { console.error('Upload error stack:', error.stack); } // Don't throw error to prevent interrupting the main analysis flow return { success: false, error: error.message }; } } /** * Prepare GitHub annotation configuration * Check environment and prerequisites * @param {Object} options - CLI options * @returns {Object} Configuration object * @private */ _prepareGitHubAnnotation(options) { // Check if github-annotate flag is enabled if (!options.githubAnnotate) { return { shouldAnnotate: false }; } // Parse mode: true/'all' -> all, 'annotate' -> annotate, 'summary' -> summary let mode = 'all'; // default if (typeof options.githubAnnotate === 'string') { const validModes = ['annotate', 'summary', 'all']; if (validModes.includes(options.githubAnnotate.toLowerCase())) { mode = options.githubAnnotate.toLowerCase(); } else { console.log(chalk.yellow(`⚠️ Invalid --github-annotate mode: ${options.githubAnnotate}. Using default: all`)); } } // Check if we're in a GitHub Actions environment const isGitHubActions = process.env.GITHUB_ACTIONS === 'true'; if (!isGitHubActions) { if (options.verbose) { console.log(chalk.yellow('⚠️ --github-annotate only works in GitHub Actions environment')); } return { shouldAnnotate: false }; } // Get GitHub environment variables const eventName = process.env.GITHUB_EVENT_NAME; const repo = process.env.GITHUB_REPOSITORY; const githubToken = process.env.GITHUB_TOKEN || options.githubToken; // Check if it's a PR event const isPullRequestEvent = eventName === 'pull_request' || eventName === 'pull_request_target'; if (!isPullRequestEvent) { if (options.verbose) { console.log(chalk.yellow(`⚠️ GitHub annotation only works on pull_request events (current: ${eventName})`)); } return { shouldAnnotate: false }; } // Get PR number from GitHub context let prNumber = null; try { // Try to get PR number from GITHUB_EVENT_PATH const eventPath = process.env.GITHUB_EVENT_PATH; if (eventPath && fs.existsSync(eventPath)) { const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); prNumber = event.pull_request?.number; } } catch (error) { if (options.verbose) { console.log(chalk.yellow(`⚠️ Failed to read GitHub event data: ${error.message}`)); } } // Fallback: try from environment variable if (!prNumber && process.env.GITHUB_PR_NUMBER) { prNumber = parseInt(process.env.GITHUB_PR_NUMBER, 10); } // Validate required data if (!repo) { console.log(chalk.yellow('⚠️ Missing GITHUB_REPOSITORY environment variable')); return { shouldAnnotate: false }; } if (!prNumber) { console.log(chalk.yellow('⚠️ Could not determine PR number from GitHub context')); return { shouldAnnotate: false }; } if (!githubToken) { console.log(chalk.yellow('⚠️ Missing GITHUB_TOKEN for authentication')); return { shouldAnnotate: false }; } // Generate temp file path if needed const tempFile = path.join( process.env.RUNNER_TEMP || '/tmp', `sunlint-report-${Date.now()}-${Math.random().toString(36).substring(2, 11)}.json` ); return { shouldAnnotate: true, mode, repo, prNumber, githubToken, tempFile, eventName }; } /** * Handle GitHub annotation process * @param {Object} config - GitHub annotation configuration * @param {string} outputFile - Path to report file * @param {boolean} shouldCleanup - Whether to cleanup temp file * @private */ async _handleGitHubAnnotation(config, outputFile, shouldCleanup) { const mode = config.mode || 'all'; const results = {}; try { console.log(chalk.blue(`🔄 GitHub PR annotation mode: ${mode}`)); if (!config.repo || !config.prNumber || !config.githubToken) { throw new Error('Missing required GitHub configuration'); } if (!outputFile || !fs.existsSync(outputFile)) { throw new Error(`Report file not found: ${outputFile}`); } // Import services const { annotate, postSummaryComment } = require('./github-annotate-service'); // Execute based on mode const shouldAnnotate = mode === 'annotate' || mode === 'all'; const shouldSummary = mode === 'summary' || mode === 'all'; // 1. Inline comments (annotate mode) if (shouldAnnotate) { try { console.log(chalk.blue('📝 Creating inline comments...')); const annotateResult = await annotate({ jsonFile: outputFile, githubToken: config.githubToken, repo: config.repo, prNumber: config.prNumber, skipDuplicates: true }); results.annotate = annotateResult; if (annotateResult.success) { console.log(chalk.green(`✅ Inline comments: ${annotateResult.stats.commentsCreated} created`)); if (annotateResult.stats.duplicatesSkipped > 0) { console.log(chalk.gray(` • Duplicates skipped: ${annotateResult.stats.duplicatesSkipped}`)); } } } catch (error) { console.log(chalk.red(`❌ Failed to create inline comments: ${error.message}`)); results.annotate = { success: false, error: error.message }; // Don't throw if we still need to create summary if (!shouldSummary) { throw error; } } } // 2. Summary comment (summary mode) if (shouldSummary) { try { console.log(chalk.blue('💬 Creating summary comment...')); const summaryResult = await postSummaryComment({ jsonFile: outputFile, githubToken: config.githubToken, repo: config.repo, prNumber: config.prNumber }); results.summary = summaryResult; if (summaryResult.success) { console.log(chalk.green(`✅ Summary comment: ${summaryResult.action}`)); if (summaryResult.stats) { console.log(chalk.gray(` • Total violations: ${summaryResult.stats.totalViolations}`)); console.log(chalk.gray(` • Errors: ${summaryResult.stats.errorCount}, Warnings: ${summaryResult.stats.warningCount}`)); } } } catch (error) { console.log(chalk.red(`❌ Failed to create summary comment: ${error.message}`)); results.summary = { success: false, error: error.message }; // Throw if both failed or if this is the only mode if (!results.annotate || !results.annotate.success) { throw error; } } } // 3. Generate full HTML report and upload as artifact // Auto-generate when using github-annotate in GitHub Actions if (process.env.GITHUB_ACTIONS === 'true') { try { console.log(chalk.blue('📊 Generating full HTML report...')); const htmlFile = this._generateHTMLReportFile(outputFile, config); if (htmlFile) { results.htmlReport = { success: true, file: htmlFile }; console.log(chalk.green(`✅ HTML report generated: ${path.basename(htmlFile)}`)); // Upload artifact directly using @actions/artifact try { const artifactService = require('./artifact-upload-service'); const prNumber = config.prNumber || 'unknown'; console.log(chalk.blue('📤 Uploading HTML report as artifact...')); const uploadResult = await artifactService.uploadArtifact(htmlFile, { artifactName: `sunlint-report-pr-${prNumber}`, retentionDays: 30 }); if (uploadResult.success) { results.artifactUpload = uploadResult; console.log(chalk.green(`✅ Artifact uploaded: ${uploadResult.artifactName}`)); console.log(chalk.gray(` • Size: ${(uploadResult.size / 1024).toFixed(2)} KB`)); console.log(chalk.gray(` • Download: ${uploadResult.url}#artifacts`)); } else { results.artifactUpload = uploadResult; console.log(chalk.yellow(`⚠️ Artifact upload failed: ${uploadResult.error}`)); if (uploadResult.fallback) { console.log(chalk.gray(` 💡 ${uploadResult.fallback}`)); } } } catch (uploadError) { console.log(chalk.yellow(`⚠️ Could not upload artifact: ${uploadError.message}`)); results.artifactUpload = { success: false, error: uploadError.message }; } // Generate rich GitHub Step Summary if (process.env.GITHUB_STEP_SUMMARY) { try { const summaryGenerator = require('./github-step-summary-generator'); // Parse violations from JSON for summary const jsonContent = fs.readFileSync(outputFile, 'utf8'); const reportData = JSON.parse(jsonContent); const violations = []; if (Array.isArray(reportData)) { for (const fileObj of reportData) { if (fileObj.messages && Array.isArray(fileObj.messages)) { for (const msg of fileObj.messages) { violations.push({ file: fileObj.filePath, line: msg.line, rule: msg.ruleId || 'unknown', severity: msg.severity === 2 ? 'error' : 'warning', message: msg.message || '' }); } } } } // Calculate stats const stats = summaryGenerator.calculateStatistics(violations); // Generate summary const artifactUrl = results.artifactUpload?.url || `https://github.com/${config.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; const summaryMarkdown = summaryGenerator.generateStepSummary( violations, stats, { prNumber: config.prNumber, artifactUrl: artifactUrl, score: null // Can add scoring if available } ); fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryMarkdown); console.log(chalk.gray(` • GitHub Step Summary updated`)); } catch (summaryError) { console.log(chalk.yellow(`⚠️ Failed to generate step summary: ${summaryError.message}`)); } } } else { results.htmlReport = { success: false, error: 'Failed to generate HTML' }; console.log(chalk.yellow('⚠️ HTML report generation failed (non-critical)')); } } catch (error) { console.log(chalk.yellow(`⚠️ Failed to generate HTML report: ${error.message}`)); results.htmlReport = { success: false, error: error.message }; // Non-critical error, don't throw } } // Final summary const successCount = [results.annotate?.success, results.summary?.success].filter(Boolean).length; const totalCount = [shouldAnnotate, shouldSummary].filter(Boolean).length; if (successCount === totalCount) { console.log(chalk.green(`\n✅ Successfully annotated PR #${config.prNumber} (${successCount}/${totalCount} tasks completed)`)); } else if (successCount > 0) { console.log(chalk.yellow(`\n⚠️ Partially completed (${successCount}/${totalCount} tasks successful)`)); } else { console.log(chalk.red(`\n❌ Annotation failed`)); } } catch (error) { console.log(chalk.red(`\n❌ Failed to annotate GitHub PR: ${error.message}`)); // Log detailed error in verbose mode if (process.env.DEBUG === 'true' && error.stack) { console.error(chalk.gray('Error stack:'), error.stack); } // Show suggestions based on error type if (error.name === 'ValidationError') { console.log(chalk.yellow('💡 Hint: Check your GitHub environment variables')); } else if (error.name === 'GitHubAPIError') { console.log(chalk.yellow('💡 Hint: Check GitHub token permissions (needs pull-requests:write)')); } } finally { // Cleanup temp file if needed if (shouldCleanup) { this._cleanupTempFile(outputFile); } } return results; } /** * Generate HTML report file * @param {string} jsonFile - Path to JSON report file * @param {Object} options - Generation options * @returns {string} Path to HTML report file * @private */ _generateHTMLReportFile(jsonFile, options = {}) { try { // Read JSON report const jsonContent = fs.readFileSync(jsonFile, 'utf8'); const reportData = JSON.parse(jsonContent); // Parse violations from JSON (ESLint format) const violations = []; if (Array.isArray(reportData)) { for (const fileObj of reportData) { if (fileObj.messages && Array.isArray(fileObj.messages)) { for (const msg of fileObj.messages) { violations.push({ file: fileObj.filePath, line: msg.line, rule: msg.ruleId || 'unknown', severity: msg.severity === 2 ? 'error' : 'warning', message: msg.message || 'No message provided' }); } } } } // Calculate scoring summary const errorCount = violations.filter(v => v.severity === 'error').length; const warningCount = violations.filter(v => v.severity === 'warning').length; const scoringSummary = this.scoringService.generateScoringSummary({ errorCount, warningCount, rulesChecked: options.rulesChecked || 1, loc: options.loc || 0 }); // Get git info const gitInfo = this.summaryReportService.getGitInfo(process.cwd()); // Generate HTML const htmlGenerator = require('./html-report-generator'); const htmlContent = htmlGenerator.generateHTMLReport(violations, { score: scoringSummary, gitInfo: gitInfo, timestamp: new Date().toISOString() }); // Create HTML file in temp directory const htmlFile = path.join( process.env.RUNNER_TEMP || '/tmp', `sunlint-full-report-${Date.now()}.html` ); fs.writeFileSync(htmlFile, htmlContent, 'utf8'); return htmlFile; } catch (error) { console.error(chalk.red(`❌ Failed to generate HTML report: ${error.message}`)); if (process.env.DEBUG === 'true' && error.stack) { console.error(chalk.gray('Error stack:'), error.stack); } return null; } } /** * Cleanup temporary file * @param {string} filePath - Path to temp file * @private */ _cleanupTempFile(filePath) { try { if (filePath && fs.existsSync(filePath)) { fs.unlinkSync(filePath); if (process.env.DEBUG === 'true') { console.log(chalk.gray(`🗑️ Cleaned up temp file: ${filePath}`)); } } } catch (error) { // Non-critical error, just log in debug mode if (process.env.DEBUG === 'true') { console.warn(chalk.yellow(`⚠️ Failed to cleanup temp file: ${error.message}`)); } } } } module.exports = OutputService;