@sun-asterisk/sunlint 1.3.24 → 1.3.26

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.
@@ -31,6 +31,38 @@
31
31
  "heuristic": ["rules/common/C019_log_level_usage/analyzer.js"]
32
32
  }
33
33
  },
34
+ "C020": {
35
+ "name": "Unused Imports",
36
+ "description": "Không import các module hoặc symbol không sử dụng",
37
+ "category": "code-quality",
38
+ "severity": "warning",
39
+ "languages": ["typescript", "javascript"],
40
+ "analyzer": "./rules/common/C020_unused_imports/analyzer.js",
41
+ "config": "./rules/common/C020_unused_imports/config.json",
42
+ "version": "1.0.0",
43
+ "status": "stable",
44
+ "tags": ["imports", "cleanup", "unused-code"],
45
+ "engineMappings": {
46
+ "eslint": ["no-unused-vars", "@typescript-eslint/no-unused-vars"],
47
+ "heuristic": ["rules/common/C020_unused_imports/analyzer.js"]
48
+ }
49
+ },
50
+ "C021": {
51
+ "name": "Import Organization",
52
+ "description": "Tổ chức và sắp xếp imports theo nhóm và thứ tự alphabet",
53
+ "category": "code-quality",
54
+ "severity": "info",
55
+ "languages": ["typescript", "javascript"],
56
+ "analyzer": "./rules/common/C021_import_organization/analyzer.js",
57
+ "config": "./rules/common/C021_import_organization/config.json",
58
+ "version": "1.0.0",
59
+ "status": "stable",
60
+ "tags": ["imports", "organization", "readability"],
61
+ "engineMappings": {
62
+ "eslint": ["import/order", "sort-imports"],
63
+ "heuristic": ["rules/common/C021_import_organization/analyzer.js"]
64
+ }
65
+ },
34
66
  "C006": {
35
67
  "name": "Function Naming Convention",
36
68
  "description": "Tên hàm phải là động từ/verb-noun pattern",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Artifact Upload Service
3
+ * Upload artifacts to GitHub Actions using @actions/artifact package
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Upload file as GitHub Actions artifact
11
+ * @param {string} filePath - Path to file to upload
12
+ * @param {Object} options - Upload options
13
+ * @param {string} options.artifactName - Name of artifact
14
+ * @param {number} options.retentionDays - Retention days (default: 30)
15
+ * @returns {Promise<Object>} Upload result
16
+ */
17
+ async function uploadArtifact(filePath, options = {}) {
18
+ // Check if running in GitHub Actions
19
+ if (process.env.GITHUB_ACTIONS !== 'true') {
20
+ return {
21
+ success: false,
22
+ error: 'Not running in GitHub Actions environment'
23
+ };
24
+ }
25
+
26
+ // Validate file exists
27
+ if (!fs.existsSync(filePath)) {
28
+ return {
29
+ success: false,
30
+ error: `File not found: ${filePath}`
31
+ };
32
+ }
33
+
34
+ try {
35
+ // Dynamically import @actions/artifact
36
+ // Using dynamic import to avoid dependency issues when not in GitHub Actions
37
+ const { DefaultArtifactClient } = await import('@actions/artifact');
38
+ const artifact = new DefaultArtifactClient();
39
+
40
+ const artifactName = options.artifactName || path.basename(filePath);
41
+ const retentionDays = options.retentionDays || 30;
42
+
43
+ // Upload artifact
44
+ const uploadResult = await artifact.uploadArtifact(
45
+ artifactName,
46
+ [filePath],
47
+ path.dirname(filePath),
48
+ {
49
+ retentionDays: retentionDays
50
+ }
51
+ );
52
+
53
+ if (uploadResult.failedItems && uploadResult.failedItems.length > 0) {
54
+ return {
55
+ success: false,
56
+ error: `Failed to upload ${uploadResult.failedItems.length} item(s)`,
57
+ details: uploadResult
58
+ };
59
+ }
60
+
61
+ return {
62
+ success: true,
63
+ artifactName: artifactName,
64
+ size: uploadResult.size || fs.statSync(filePath).size,
65
+ id: uploadResult.id,
66
+ url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
67
+ };
68
+
69
+ } catch (error) {
70
+ // If @actions/artifact is not available, provide helpful message
71
+ if (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND') {
72
+ return {
73
+ success: false,
74
+ error: '@actions/artifact package not found. Install with: npm install @actions/artifact',
75
+ fallback: 'Use actions/upload-artifact@v4 in workflow instead'
76
+ };
77
+ }
78
+
79
+ return {
80
+ success: false,
81
+ error: error.message,
82
+ stack: error.stack
83
+ };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Check if artifact upload is available
89
+ * @returns {Promise<boolean>}
90
+ */
91
+ async function isArtifactUploadAvailable() {
92
+ if (process.env.GITHUB_ACTIONS !== 'true') {
93
+ return false;
94
+ }
95
+
96
+ try {
97
+ await import('@actions/artifact');
98
+ return true;
99
+ } catch (error) {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ module.exports = {
105
+ uploadArtifact,
106
+ isArtifactUploadAvailable
107
+ };
@@ -37,7 +37,7 @@ function createCliProgram() {
37
37
  .option('--output-summary <file>', 'Output summary report file path (JSON format for CI/CD)')
38
38
  .option('--upload-report [url]', 'Upload summary report to API endpoint after analysis (default: Sun* Coding Standards API)')
39
39
  .option('--config <file>', 'Configuration file path (default: auto-discover)')
40
- .option('--github-annotate [mode]', 'Annotate GitHub PR: annotate (inline), summary (comment), all (both) - default: all');
40
+ .option('--github-annotate [mode]', 'Annotate GitHub PR with comments, summary & HTML report artifact (modes: annotate, summary, all)');
41
41
 
42
42
  // File targeting options
43
43
  program
@@ -136,11 +136,11 @@ CI/CD Integration:
136
136
  $ sunlint --all --output-summary=report.json --upload-report=https://custom-api.com/reports
137
137
 
138
138
  GitHub Actions Integration:
139
- $ sunlint --all --input=src --github-annotate # Both inline + summary (default)
139
+ $ sunlint --all --input=src --github-annotate # Inline + summary + HTML artifact
140
140
  $ sunlint --all --input=src --github-annotate=annotate # Inline comments only
141
- $ sunlint --all --input=src --github-annotate=summary # Summary comment only
142
- $ sunlint --all --input=src --github-annotate=all # Both inline + summary
143
- $ sunlint --all --changed-files --github-annotate # With changed files
141
+ $ sunlint --all --input=src --github-annotate=summary # Summary comment + HTML artifact
142
+ $ sunlint --all --input=src --github-annotate=all # All features (default)
143
+ $ sunlint --all --changed-files --github-annotate # With changed files only
144
144
 
145
145
  ESLint Integration:
146
146
  $ sunlint --typescript --eslint-integration --input=src
@@ -915,19 +915,76 @@ async function postSummaryComment({
915
915
 
916
916
  // List top 10 files with most issues
917
917
  const sortedFiles = Object.entries(fileGroups)
918
- .sort((a, b) => b[1].length - a[1].length)
919
- .slice(0, 10);
918
+ .sort((a, b) => b[1].length - a[1].length);
920
919
 
921
- for (const [file, fileViolations] of sortedFiles) {
920
+ const top10Files = sortedFiles.slice(0, 10);
921
+
922
+ for (const [file, fileViolations] of top10Files) {
922
923
  const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
923
924
  const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
924
925
  summary += `- \`${file}\`: ${fileErrors} error(s), ${fileWarnings} warning(s)\n`;
925
926
  }
926
927
 
927
- if (Object.keys(fileGroups).length > 10) {
928
- summary += `\n_... and ${Object.keys(fileGroups).length - 10} more file(s)_\n`;
928
+ // Add collapsible section with ALL files if more than 10
929
+ const totalFilesWithIssues = Object.keys(fileGroups).length;
930
+ if (totalFilesWithIssues > 10) {
931
+ summary += `\n<details>\n`;
932
+ summary += `<summary>📋 View all ${totalFilesWithIssues} files with issues</summary>\n\n`;
933
+ summary += `| File | Errors | Warnings | Total |\n`;
934
+ summary += `|------|--------|----------|-------|\n`;
935
+
936
+ for (const [file, fileViolations] of sortedFiles) {
937
+ const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
938
+ const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
939
+ const total = fileViolations.length;
940
+ summary += `| \`${file}\` | ${fileErrors} | ${fileWarnings} | ${total} |\n`;
941
+ }
942
+
943
+ summary += `\n</details>\n`;
944
+ }
945
+
946
+ // Add collapsible section with violations grouped by rule
947
+ summary += `\n<details>\n`;
948
+ summary += `<summary>🔍 View violations by rule</summary>\n\n`;
949
+
950
+ // Group violations by rule
951
+ const ruleGroups = {};
952
+ for (const v of violations) {
953
+ if (!ruleGroups[v.rule]) {
954
+ ruleGroups[v.rule] = [];
955
+ }
956
+ ruleGroups[v.rule].push(v);
929
957
  }
930
958
 
959
+ // Sort rules by count (descending)
960
+ const sortedRules = Object.entries(ruleGroups)
961
+ .sort((a, b) => b[1].length - a[1].length);
962
+
963
+ for (const [ruleId, ruleViolations] of sortedRules) {
964
+ const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length;
965
+ const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length;
966
+ const severityBadge = ruleErrors > 0 ? '🔴' : '🟡';
967
+
968
+ summary += `\n**${severityBadge} ${ruleId}** (${ruleViolations.length} violation${ruleViolations.length > 1 ? 's' : ''}`;
969
+ summary += ` - ${ruleErrors} error${ruleErrors !== 1 ? 's' : ''}, ${ruleWarnings} warning${ruleWarnings !== 1 ? 's' : ''})\n\n`;
970
+
971
+ // Show first 5 locations for this rule
972
+ const locationsToShow = ruleViolations.slice(0, 5);
973
+ for (const v of locationsToShow) {
974
+ summary += `- \`${v.file}:${v.line}\``;
975
+ if (v.message && v.message.length < 80) {
976
+ summary += ` - ${v.message}`;
977
+ }
978
+ summary += `\n`;
979
+ }
980
+
981
+ if (ruleViolations.length > 5) {
982
+ summary += `\n_... and ${ruleViolations.length - 5} more location(s)_\n`;
983
+ }
984
+ }
985
+
986
+ summary += `\n</details>\n`;
987
+
931
988
  summary += '\n⚠️ Please check the inline comments on your code for details.\n';
932
989
  }
933
990
 
@@ -937,7 +994,12 @@ async function postSummaryComment({
937
994
  // Add link to full report if available
938
995
  if (process.env.GITHUB_RUN_ID) {
939
996
  const runUrl = `https://github.com/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
940
- summary += `[View full report](${runUrl})`;
997
+ summary += `[View workflow run](${runUrl})`;
998
+
999
+ // Add link to HTML report artifact if available
1000
+ if (process.env.GITHUB_ACTIONS === 'true') {
1001
+ summary += ` • [📥 Download full HTML report](${runUrl}#artifacts)`;
1002
+ }
941
1003
  }
942
1004
 
943
1005
  summary += '</sub>\n';
@@ -0,0 +1,277 @@
1
+ /**
2
+ * GitHub Step Summary Generator
3
+ * Generate rich markdown summary for GitHub Actions workflow
4
+ */
5
+
6
+ /**
7
+ * Generate comprehensive GitHub Step Summary
8
+ * @param {Array} violations - Array of violations
9
+ * @param {Object} stats - Statistics object
10
+ * @param {Object} metadata - Additional metadata
11
+ * @returns {string} Markdown summary
12
+ */
13
+ function generateStepSummary(violations, stats, metadata = {}) {
14
+ const {
15
+ totalViolations,
16
+ errorCount,
17
+ warningCount,
18
+ filesWithIssues,
19
+ fileGroups,
20
+ ruleGroups
21
+ } = stats;
22
+
23
+ const { prNumber, artifactUrl, score } = metadata;
24
+
25
+ let summary = '';
26
+
27
+ // Header
28
+ const emoji = errorCount > 0 ? '❌' : warningCount > 0 ? '⚠️' : '✅';
29
+ const status = errorCount > 0 ? 'Failed' : warningCount > 0 ? 'Warning' : 'Passed';
30
+
31
+ summary += `# ${emoji} SunLint Quality Report\n\n`;
32
+
33
+ // Status badges
34
+ if (errorCount > 0) {
35
+ summary += `> **Status:** 🔴 ${status} - ${errorCount} error(s) must be fixed\n\n`;
36
+ } else if (warningCount > 0) {
37
+ summary += `> **Status:** 🟡 ${status} - ${warningCount} warning(s) found\n\n`;
38
+ } else {
39
+ summary += `> **Status:** ✅ ${status} - No violations found!\n\n`;
40
+ }
41
+
42
+ // Quick Stats
43
+ summary += `## 📊 Summary\n\n`;
44
+ summary += `| Metric | Value |\n`;
45
+ summary += `|--------|-------|\n`;
46
+ summary += `| Total Violations | **${totalViolations}** |\n`;
47
+ summary += `| Errors | **${errorCount}** 🔴 |\n`;
48
+ summary += `| Warnings | **${warningCount}** 🟡 |\n`;
49
+ summary += `| Files with Issues | **${filesWithIssues}** |\n`;
50
+
51
+ if (score && score.score !== undefined) {
52
+ summary += `| Quality Score | **${score.score}/100** (${score.grade || 'N/A'}) |\n`;
53
+ }
54
+
55
+ summary += `\n`;
56
+
57
+ if (totalViolations === 0) {
58
+ summary += `---\n\n`;
59
+ summary += `### 🎉 Excellent Work!\n\n`;
60
+ summary += `No coding standard violations detected in this PR.\n\n`;
61
+
62
+ if (artifactUrl) {
63
+ summary += `📥 [Download detailed HTML report](${artifactUrl}#artifacts)\n\n`;
64
+ }
65
+
66
+ return summary;
67
+ }
68
+
69
+ // Top Files with Issues
70
+ summary += `## 📁 Top Files with Issues\n\n`;
71
+
72
+ const sortedFiles = Object.entries(fileGroups)
73
+ .sort((a, b) => b[1].length - a[1].length)
74
+ .slice(0, 15); // Top 15 files
75
+
76
+ summary += `| File | Errors | Warnings | Total |\n`;
77
+ summary += `|------|--------|----------|-------|\n`;
78
+
79
+ for (const [file, fileViolations] of sortedFiles) {
80
+ const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
81
+ const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
82
+ const total = fileViolations.length;
83
+
84
+ const errorBadge = fileErrors > 0 ? '🔴' : '';
85
+ const warningBadge = fileWarnings > 0 && fileErrors === 0 ? '🟡' : '';
86
+
87
+ summary += `| \`${truncatePath(file, 60)}\` ${errorBadge}${warningBadge} | ${fileErrors} | ${fileWarnings} | **${total}** |\n`;
88
+ }
89
+
90
+ if (Object.keys(fileGroups).length > 15) {
91
+ summary += `\n_... and ${Object.keys(fileGroups).length - 15} more file(s)_\n`;
92
+ }
93
+
94
+ summary += `\n`;
95
+
96
+ // All Files (Collapsible)
97
+ if (Object.keys(fileGroups).length > 15) {
98
+ summary += `<details>\n`;
99
+ summary += `<summary>📋 View all ${Object.keys(fileGroups).length} files</summary>\n\n`;
100
+
101
+ const allFiles = Object.entries(fileGroups)
102
+ .sort((a, b) => b[1].length - a[1].length);
103
+
104
+ summary += `| File | Errors | Warnings | Total |\n`;
105
+ summary += `|------|--------|----------|-------|\n`;
106
+
107
+ for (const [file, fileViolations] of allFiles) {
108
+ const fileErrors = fileViolations.filter(v => v.severity === 'error').length;
109
+ const fileWarnings = fileViolations.filter(v => v.severity === 'warning').length;
110
+ const total = fileViolations.length;
111
+
112
+ summary += `| \`${truncatePath(file, 60)}\` | ${fileErrors} | ${fileWarnings} | ${total} |\n`;
113
+ }
114
+
115
+ summary += `\n</details>\n\n`;
116
+ }
117
+
118
+ // Top Violations by Rule
119
+ summary += `## 🔍 Top Violations by Rule\n\n`;
120
+
121
+ const sortedRules = Object.entries(ruleGroups)
122
+ .sort((a, b) => b[1].length - a[1].length)
123
+ .slice(0, 10); // Top 10 rules
124
+
125
+ summary += `| Rule | Errors | Warnings | Total | Locations |\n`;
126
+ summary += `|------|--------|----------|-------|------------|\n`;
127
+
128
+ for (const [ruleId, ruleViolations] of sortedRules) {
129
+ const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length;
130
+ const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length;
131
+ const total = ruleViolations.length;
132
+ const badge = ruleErrors > 0 ? '🔴' : '🟡';
133
+
134
+ // Sample locations
135
+ const sampleLocations = ruleViolations.slice(0, 3).map(v =>
136
+ `\`${truncatePath(v.file, 30)}:${v.line}\``
137
+ ).join(', ');
138
+ const more = ruleViolations.length > 3 ? `, +${ruleViolations.length - 3} more` : '';
139
+
140
+ summary += `| **${badge} ${ruleId}** | ${ruleErrors} | ${ruleWarnings} | **${total}** | ${sampleLocations}${more} |\n`;
141
+ }
142
+
143
+ if (Object.keys(ruleGroups).length > 10) {
144
+ summary += `\n_... and ${Object.keys(ruleGroups).length - 10} more rule(s)_\n`;
145
+ }
146
+
147
+ summary += `\n`;
148
+
149
+ // All Rules (Collapsible)
150
+ if (Object.keys(ruleGroups).length > 10) {
151
+ summary += `<details>\n`;
152
+ summary += `<summary>📜 View all ${Object.keys(ruleGroups).length} rules</summary>\n\n`;
153
+
154
+ const allRules = Object.entries(ruleGroups)
155
+ .sort((a, b) => b[1].length - a[1].length);
156
+
157
+ for (const [ruleId, ruleViolations] of allRules) {
158
+ const ruleErrors = ruleViolations.filter(v => v.severity === 'error').length;
159
+ const ruleWarnings = ruleViolations.filter(v => v.severity === 'warning').length;
160
+ const badge = ruleErrors > 0 ? '🔴' : '🟡';
161
+
162
+ summary += `### ${badge} ${ruleId} (${ruleViolations.length} violations)\n\n`;
163
+
164
+ // Show first 5 locations
165
+ const locationsToShow = ruleViolations.slice(0, 5);
166
+ for (const v of locationsToShow) {
167
+ summary += `- \`${v.file}:${v.line}\``;
168
+ if (v.message && v.message.length < 100) {
169
+ summary += ` - ${v.message}`;
170
+ }
171
+ summary += `\n`;
172
+ }
173
+
174
+ if (ruleViolations.length > 5) {
175
+ summary += `\n_... and ${ruleViolations.length - 5} more location(s)_\n`;
176
+ }
177
+
178
+ summary += `\n`;
179
+ }
180
+
181
+ summary += `</details>\n\n`;
182
+ }
183
+
184
+ // Download Links
185
+ summary += `---\n\n`;
186
+ summary += `## 📥 Download Full Report\n\n`;
187
+
188
+ if (artifactUrl) {
189
+ summary += `The complete interactive HTML report is available for download:\n\n`;
190
+ summary += `- **[📥 Download HTML Report](${artifactUrl}#artifacts)** - Interactive report with search, filter, and sorting\n`;
191
+ summary += `- **Features:** Detailed violations, quality metrics, exportable results\n`;
192
+ summary += `- **Retention:** 30 days\n\n`;
193
+ }
194
+
195
+ // Footer
196
+ summary += `---\n\n`;
197
+ summary += `<sub>Generated by [SunLint](https://github.com/sun-asterisk/engineer-excellence) • `;
198
+ if (prNumber) {
199
+ summary += `PR #${prNumber} • `;
200
+ }
201
+ summary += `${new Date().toLocaleString()}</sub>\n`;
202
+
203
+ return summary;
204
+ }
205
+
206
+ /**
207
+ * Calculate statistics from violations
208
+ * @param {Array} violations - Violations array
209
+ * @returns {Object} Statistics object
210
+ */
211
+ function calculateStatistics(violations) {
212
+ const totalViolations = violations.length;
213
+ const errorCount = violations.filter(v => v.severity === 'error').length;
214
+ const warningCount = violations.filter(v => v.severity === 'warning').length;
215
+
216
+ // Group by file
217
+ const fileGroups = {};
218
+ for (const v of violations) {
219
+ if (!fileGroups[v.file]) {
220
+ fileGroups[v.file] = [];
221
+ }
222
+ fileGroups[v.file].push(v);
223
+ }
224
+
225
+ // Group by rule
226
+ const ruleGroups = {};
227
+ for (const v of violations) {
228
+ if (!ruleGroups[v.rule]) {
229
+ ruleGroups[v.rule] = [];
230
+ }
231
+ ruleGroups[v.rule].push(v);
232
+ }
233
+
234
+ const filesWithIssues = Object.keys(fileGroups).length;
235
+
236
+ return {
237
+ totalViolations,
238
+ errorCount,
239
+ warningCount,
240
+ filesWithIssues,
241
+ fileGroups,
242
+ ruleGroups
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Truncate path for display
248
+ * @param {string} path - File path
249
+ * @param {number} maxLength - Max length
250
+ * @returns {string} Truncated path
251
+ */
252
+ function truncatePath(path, maxLength) {
253
+ if (path.length <= maxLength) {
254
+ return path;
255
+ }
256
+
257
+ const parts = path.split('/');
258
+ if (parts.length <= 2) {
259
+ return '...' + path.slice(-(maxLength - 3));
260
+ }
261
+
262
+ // Keep first and last parts
263
+ const first = parts[0];
264
+ const last = parts[parts.length - 1];
265
+ const remaining = maxLength - first.length - last.length - 5; // 5 for ".../"
266
+
267
+ if (remaining < 0) {
268
+ return '...' + path.slice(-(maxLength - 3));
269
+ }
270
+
271
+ return `${first}/.../${last}`;
272
+ }
273
+
274
+ module.exports = {
275
+ generateStepSummary,
276
+ calculateStatistics
277
+ };