@sun-asterisk/sunlint 1.3.9 → 1.3.11

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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Summary Report Service
3
+ * Generate summary reports for CI/CD and management dashboards
4
+ * Following Rule C005: Single responsibility - handle summary report generation
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+
11
+ class SummaryReportService {
12
+ constructor() {}
13
+
14
+ /**
15
+ * Get Git repository information
16
+ * @param {string} cwd - Working directory
17
+ * @returns {Object} Git info including repository URL, branch, and commit hash
18
+ */
19
+ getGitInfo(cwd = process.cwd()) {
20
+ const gitInfo = {
21
+ repository_url: null,
22
+ branch: null,
23
+ commit_hash: null
24
+ };
25
+
26
+ try {
27
+ // Check if it's a git repository
28
+ execSync('git rev-parse --git-dir', { cwd, stdio: 'ignore' });
29
+
30
+ // Get repository URL (prefer origin)
31
+ try {
32
+ const remoteUrl = execSync('git config --get remote.origin.url', { cwd, encoding: 'utf8' }).trim();
33
+ // Convert SSH URL to HTTPS URL if needed
34
+ if (remoteUrl.startsWith('git@')) {
35
+ gitInfo.repository_url = remoteUrl
36
+ .replace('git@', 'https://')
37
+ .replace('.com:', '.com/')
38
+ .replace('.git', '');
39
+ } else {
40
+ gitInfo.repository_url = remoteUrl.replace('.git', '');
41
+ }
42
+ } catch (error) {
43
+ // No remote configured
44
+ }
45
+
46
+ // Get current branch
47
+ try {
48
+ gitInfo.branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8' }).trim();
49
+ } catch (error) {
50
+ // Can't get branch
51
+ }
52
+
53
+ // Get current commit hash
54
+ try {
55
+ gitInfo.commit_hash = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
56
+ } catch (error) {
57
+ // Can't get commit hash
58
+ }
59
+ } catch (error) {
60
+ // Not a git repository - return nulls
61
+ }
62
+
63
+ return gitInfo;
64
+ }
65
+
66
+ /**
67
+ * Generate summary report from violations
68
+ * @param {Array} violations - Array of violations
69
+ * @param {Object} scoringSummary - Scoring summary with score and metrics
70
+ * @param {Object} options - Additional options
71
+ * @returns {Object} Summary report in JSON format
72
+ */
73
+ generateSummaryReport(violations, scoringSummary, options = {}) {
74
+ // Get Git information
75
+ const gitInfo = this.getGitInfo(options.cwd);
76
+
77
+ // Override with environment variables if available (from CI/CD)
78
+ const repository_url = process.env.GITHUB_REPOSITORY
79
+ ? `https://github.com/${process.env.GITHUB_REPOSITORY}`
80
+ : gitInfo.repository_url;
81
+
82
+ const branch = process.env.GITHUB_REF_NAME || gitInfo.branch;
83
+ const commit_hash = process.env.GITHUB_SHA || gitInfo.commit_hash;
84
+
85
+ // Count violations by rule
86
+ const violationsByRule = {};
87
+ violations.forEach(violation => {
88
+ const ruleId = violation.ruleId || 'unknown';
89
+ if (!violationsByRule[ruleId]) {
90
+ violationsByRule[ruleId] = {
91
+ rule_code: ruleId,
92
+ count: 0,
93
+ severity: violation.severity || 'warning'
94
+ };
95
+ }
96
+ violationsByRule[ruleId].count++;
97
+ });
98
+
99
+ // Convert to array and sort by count (descending)
100
+ const violationsSummary = Object.values(violationsByRule)
101
+ .sort((a, b) => b.count - a.count);
102
+
103
+ // Build the summary report
104
+ const summaryReport = {
105
+ metadata: {
106
+ generated_at: new Date().toISOString(),
107
+ tool: 'SunLint',
108
+ version: options.version || '1.3.9',
109
+ analysis_duration_ms: options.duration || 0
110
+ },
111
+ repository: {
112
+ repository_url,
113
+ branch,
114
+ commit_hash
115
+ },
116
+ quality: {
117
+ score: scoringSummary.score,
118
+ grade: scoringSummary.grade,
119
+ metrics: scoringSummary.metrics
120
+ },
121
+ violations: {
122
+ total: violations.length,
123
+ by_severity: {
124
+ errors: scoringSummary.metrics.errors,
125
+ warnings: scoringSummary.metrics.warnings
126
+ },
127
+ by_rule: violationsSummary
128
+ },
129
+ analysis: {
130
+ files_analyzed: options.filesAnalyzed || 0,
131
+ rules_checked: scoringSummary.metrics.rulesChecked,
132
+ lines_of_code: scoringSummary.metrics.linesOfCode
133
+ }
134
+ };
135
+
136
+ return summaryReport;
137
+ }
138
+
139
+ /**
140
+ * Generate summary report and save to file
141
+ * @param {Array} violations - Array of violations
142
+ * @param {Object} scoringSummary - Scoring summary
143
+ * @param {string} outputPath - Output file path
144
+ * @param {Object} options - Additional options
145
+ */
146
+ saveSummaryReport(violations, scoringSummary, outputPath, options = {}) {
147
+ const summaryReport = this.generateSummaryReport(violations, scoringSummary, options);
148
+
149
+ // Ensure directory exists
150
+ const dir = path.dirname(outputPath);
151
+ if (!fs.existsSync(dir)) {
152
+ fs.mkdirSync(dir, { recursive: true });
153
+ }
154
+
155
+ // Write to file with pretty formatting
156
+ fs.writeFileSync(outputPath, JSON.stringify(summaryReport, null, 2), 'utf8');
157
+
158
+ return summaryReport;
159
+ }
160
+
161
+ /**
162
+ * Generate a simple text summary for console display
163
+ * @param {Object} summaryReport - Summary report object
164
+ * @returns {string} Formatted text summary
165
+ */
166
+ formatTextSummary(summaryReport) {
167
+ const { quality, violations, analysis } = summaryReport;
168
+
169
+ let output = '\n📊 Quality Summary Report\n';
170
+ output += '━'.repeat(50) + '\n';
171
+ output += `📈 Quality Score: ${quality.score} (Grade: ${quality.grade})\n`;
172
+ output += `📁 Files Analyzed: ${analysis.files_analyzed}\n`;
173
+ output += `📋 Rules Checked: ${analysis.rules_checked}\n`;
174
+ output += `📏 Lines of Code: ${analysis.lines_of_code.toLocaleString()}\n`;
175
+ output += `⚠️ Total Violations: ${violations.total} (${violations.by_severity.errors} errors, ${violations.by_severity.warnings} warnings)\n`;
176
+ output += `📊 Violations per KLOC: ${quality.metrics.violationsPerKLOC}\n`;
177
+
178
+ if (violations.by_rule.length > 0) {
179
+ output += '\n🔍 Top Violations by Rule:\n';
180
+ violations.by_rule.slice(0, 10).forEach((item, index) => {
181
+ output += ` ${index + 1}. ${item.rule_code}: ${item.count} violations (${item.severity})\n`;
182
+ });
183
+ }
184
+
185
+ return output;
186
+ }
187
+ }
188
+
189
+ module.exports = SummaryReportService;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Upload Service
3
+ */
4
+
5
+ const { execSync } = require('child_process');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const chalk = require('chalk');
10
+
11
+ class UploadService {
12
+ /**
13
+ * Upload report file to API endpoint using curl
14
+ */
15
+ async uploadReportToApi(filePath, apiUrl, options = {}) {
16
+ try {
17
+ this.validateUploadParameters(filePath, apiUrl);
18
+
19
+ const oidcToken = this.getOidcToken();
20
+ console.log(chalk.blue(`📤 Uploading report to: ${apiUrl}`));
21
+
22
+ if (oidcToken) {
23
+ console.log(chalk.green(`🔐 Using OIDC authentication (CI detected)`));
24
+ }
25
+
26
+ const uploadResult = await this.executeUploadCommand(filePath, apiUrl, options);
27
+
28
+ console.log(chalk.green(`✅ Report uploaded successfully!`));
29
+ return {
30
+ success: true,
31
+ url: apiUrl,
32
+ filePath: filePath,
33
+ response: uploadResult.response,
34
+ statusCode: uploadResult.statusCode
35
+ };
36
+
37
+ } catch (error) {
38
+ const errorContext = {
39
+ filePath,
40
+ apiUrl,
41
+ error: error.message,
42
+ timestamp: new Date().toISOString()
43
+ };
44
+
45
+ console.error(chalk.red('❌ Upload failed:'), error.message);
46
+
47
+ if (options.verbose || options.debug) {
48
+ console.error('Upload error context:', errorContext);
49
+ }
50
+
51
+ return {
52
+ success: false,
53
+ url: apiUrl,
54
+ filePath: filePath,
55
+ error: error.message,
56
+ errorContext
57
+ };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Validate upload parameters
63
+ */
64
+ validateUploadParameters(filePath, apiUrl) {
65
+ if (!filePath) {
66
+ throw new Error('File path is required for upload');
67
+ }
68
+
69
+ if (!apiUrl) {
70
+ throw new Error('API URL is required for upload');
71
+ }
72
+
73
+ if (!fs.existsSync(filePath)) {
74
+ throw new Error(`Upload file does not exist: ${filePath}`);
75
+ }
76
+
77
+ // Basic URL validation
78
+ try {
79
+ new URL(apiUrl);
80
+ } catch (error) {
81
+ throw new Error(`Invalid API URL format: ${apiUrl}`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Execute curl command to upload file
87
+ */
88
+ async executeUploadCommand(filePath, apiUrl, options = {}) {
89
+ const fileName = path.basename(filePath);
90
+ const timeout = options.timeout || 30; // 30 seconds default timeout
91
+
92
+ // Build curl command
93
+ const curlCommand = this.buildCurlCommand(filePath, apiUrl, fileName, timeout);
94
+
95
+ try {
96
+ // Execute curl command synchronously for simplicity
97
+ const output = execSync(curlCommand, {
98
+ encoding: 'utf8',
99
+ maxBuffer: 1024 * 1024, // 1MB buffer
100
+ timeout: timeout * 1000 // Convert to milliseconds
101
+ });
102
+
103
+ // Parse response if possible
104
+ let response = output.trim();
105
+ let statusCode = null;
106
+
107
+ // Try to extract status code from curl verbose output
108
+ const statusMatch = response.match(/HTTP\/[\d.]+\s+(\d{3})/);
109
+ if (statusMatch) {
110
+ statusCode = parseInt(statusMatch[1]);
111
+ }
112
+
113
+ return {
114
+ response: response,
115
+ statusCode: statusCode
116
+ };
117
+
118
+ } catch (error) {
119
+ throw new Error(`curl command failed: ${error.message}. Command: ${curlCommand}`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Build curl command for file upload
125
+ */
126
+ buildCurlCommand(filePath, apiUrl, fileName, timeout) {
127
+ const curlOptions = [
128
+ 'curl',
129
+ '-X POST',
130
+ `--connect-timeout ${timeout}`,
131
+ `--max-time ${timeout}`,
132
+ '-H "Content-Type: application/json"',
133
+ '-H "User-Agent: SunLint-Report-Uploader/1.0"'
134
+ ];
135
+
136
+ // Add Idempotency-Key for safe retries
137
+ const idempotencyKey = this.generateIdempotencyKey(filePath);
138
+ curlOptions.push(`-H "Idempotency-Key: ${idempotencyKey}"`);
139
+
140
+ // Add OIDC token if running in CI environment
141
+ const oidcToken = this.getOidcToken();
142
+ if (oidcToken) {
143
+ curlOptions.push(`-H "Authorization: Bearer ${oidcToken}"`);
144
+ }
145
+
146
+ curlOptions.push(
147
+ `--data-binary @"${filePath}"`,
148
+ `"${apiUrl}"`
149
+ );
150
+
151
+ return curlOptions.join(' ');
152
+ }
153
+
154
+ /**
155
+ * Check if curl is available on system
156
+ */
157
+ checkCurlAvailability() {
158
+ try {
159
+ execSync('curl --version', { stdio: 'ignore' });
160
+ return true;
161
+ } catch (error) {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Get OIDC token from environment variables (for CI authentication)
168
+ */
169
+ getOidcToken() {
170
+ // Try to get GitHub Actions OIDC token first (requires API call)
171
+ if (process.env.GITHUB_ACTIONS && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
172
+ try {
173
+ const githubToken = this.requestGitHubOidcToken();
174
+ if (githubToken) {
175
+ return githubToken;
176
+ }
177
+ } catch (error) {
178
+ console.log(chalk.yellow(`⚠️ Failed to get GitHub OIDC token: ${error.message}`));
179
+ }
180
+ }
181
+
182
+ // Check if running in GitHub Actions without OIDC token
183
+ if (process.env.GITHUB_ACTIONS) {
184
+ console.log(chalk.yellow('⚠️ Running in GitHub Actions but no OIDC token available. Upload may require authentication.'));
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Request OIDC token from GitHub Actions
192
+ */
193
+ requestGitHubOidcToken() {
194
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
195
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
196
+
197
+ if (!requestToken || !requestUrl) {
198
+ throw new Error('Missing GitHub OIDC request credentials');
199
+ }
200
+
201
+ try {
202
+ // Use curl to request OIDC token from GitHub with specific audience
203
+ const curlCommand = `curl -H "Authorization: bearer ${requestToken}" "${requestUrl}&audience=coding-standards-report-api"`;
204
+
205
+ const response = execSync(curlCommand, {
206
+ encoding: 'utf8',
207
+ timeout: 10000, // 10 second timeout
208
+ stdio: 'pipe'
209
+ });
210
+
211
+ const responseData = JSON.parse(response);
212
+
213
+ if (responseData.value) {
214
+ return responseData.value;
215
+ } else {
216
+ throw new Error('No token in response');
217
+ }
218
+
219
+ } catch (error) {
220
+ throw new Error(`GitHub OIDC request failed: ${error.message}`);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Generate idempotency key for safe retries
226
+ */
227
+ generateIdempotencyKey(filePath) {
228
+ // Create deterministic key based on file content and timestamp
229
+ const fileContent = fs.readFileSync(filePath, 'utf8');
230
+ const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
231
+
232
+ // Include CI context for uniqueness across environments and attempts
233
+ const ciContext = process.env.GITHUB_ACTIONS ?
234
+ `${process.env.GITHUB_REPOSITORY || 'unknown'}-${process.env.GITHUB_RUN_ID || 'local'}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` :
235
+ 'local';
236
+
237
+ // Create hash from content + date + CI context + run attempt
238
+ const hashInput = `${fileContent}-${timestamp}-${ciContext}`;
239
+ const hash = crypto.createHash('sha256').update(hashInput).digest('hex');
240
+
241
+ // Return first 32 characters for reasonable key length
242
+ return `sunlint-${hash.substring(0, 24)}`;
243
+ }
244
+
245
+ /**
246
+ * Validate API endpoint accessibility
247
+ */
248
+ async validateApiEndpoint(apiUrl, options = {}) {
249
+ try {
250
+ const timeout = options.timeout || 10;
251
+
252
+ // Build command with auth header if available
253
+ let testCommand = `curl -X HEAD --connect-timeout ${timeout} --max-time ${timeout} -s -o /dev/null -w "%{http_code}"`;
254
+
255
+ const oidcToken = this.getOidcToken();
256
+ if (oidcToken) {
257
+ testCommand += ` -H "Authorization: Bearer ${oidcToken}"`;
258
+ }
259
+
260
+ testCommand += ` "${apiUrl}"`;
261
+
262
+ const statusCode = execSync(testCommand, { encoding: 'utf8' }).trim();
263
+
264
+ return {
265
+ accessible: true,
266
+ statusCode: parseInt(statusCode),
267
+ url: apiUrl,
268
+ authenticated: !!oidcToken
269
+ };
270
+
271
+ } catch (error) {
272
+ return {
273
+ accessible: false,
274
+ error: error.message,
275
+ url: apiUrl,
276
+ authenticated: !!this.getOidcToken()
277
+ };
278
+ }
279
+ }
280
+ }
281
+
282
+ module.exports = UploadService;