@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
JavaScript
/**
* 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;