@sun-asterisk/sunlint 1.3.25 → 1.3.27
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.
- package/config/rules/enhanced-rules-registry.json +99 -16
- package/core/artifact-upload-service.js +107 -0
- package/core/cli-program.js +5 -5
- package/core/github-annotate-service.js +68 -6
- package/core/github-step-summary-generator.js +277 -0
- package/core/html-report-generator.js +839 -0
- package/core/output-service.js +170 -0
- package/package.json +1 -1
- package/rules/common/C029_catch_block_logging/analyzer.js +47 -12
- package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +35 -15
- package/rules/security/S003_open_redirect_protection/README.md +371 -0
- package/rules/security/S003_open_redirect_protection/analyzer.js +135 -0
- package/rules/security/S003_open_redirect_protection/config.json +58 -0
- package/rules/security/S003_open_redirect_protection/symbol-based-analyzer.js +884 -0
- package/rules/security/S004_sensitive_data_logging/analyzer.js +135 -0
- package/rules/security/S004_sensitive_data_logging/config.json +62 -0
- package/rules/security/S004_sensitive_data_logging/symbol-based-analyzer.js +592 -0
- package/rules/security/S012_hardcoded_secrets/analyzer.js +149 -0
- package/rules/security/S012_hardcoded_secrets/config.json +75 -0
- package/rules/security/S012_hardcoded_secrets/symbol-based-analyzer.js +1204 -0
- package/rules/security/S019_smtp_injection_protection/analyzer.js +120 -0
- package/rules/security/S019_smtp_injection_protection/config.json +35 -0
- package/rules/security/S019_smtp_injection_protection/symbol-based-analyzer.js +687 -0
- package/rules/security/S022_escape_output_context/README.md +254 -0
- package/rules/security/S022_escape_output_context/analyzer.js +510 -0
- package/rules/security/S022_escape_output_context/config.json +229 -0
- package/rules/security/S023_no_json_injection/analyzer.js +15 -0
- package/rules/security/S023_no_json_injection/ast-analyzer.js +18 -3
- package/rules/security/S023_no_json_injection/config.json +133 -0
- package/rules/security/S024_xpath_xxe_protection/regex-based-analyzer.js +41 -0
- package/rules/security/S027_no_hardcoded_secrets/analyzer.js +67 -8
- package/rules/security/S027_no_hardcoded_secrets/categorized-analyzer.js +29 -6
- package/rules/security/S029_csrf_protection/config.json +127 -0
- package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +160 -28
- package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +81 -19
- package/rules/security/S031_secure_session_cookies/analyzer.js +20 -2
- package/rules/security/S031_secure_session_cookies/regex-based-analyzer.js +100 -0
- package/rules/security/S031_secure_session_cookies/symbol-based-analyzer.js +8 -1
- package/rules/security/S032_httponly_session_cookies/analyzer.js +2 -2
- package/rules/security/S032_httponly_session_cookies/regex-based-analyzer.js +115 -0
- package/rules/security/S032_httponly_session_cookies/symbol-based-analyzer.js +39 -10
- package/rules/security/S036_lfi_rfi_protection/analyzer.js +224 -0
- package/rules/security/S036_lfi_rfi_protection/config.json +20 -0
- package/rules/security/S040_session_fixation_protection/analyzer.js +153 -0
- package/rules/security/S040_session_fixation_protection/config.json +20 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/README.md +83 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/analyzer.js +153 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/config.json +41 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/symbol-based-analyzer.js +1139 -0
|
@@ -496,16 +496,62 @@
|
|
|
496
496
|
"tags": ["security", "idor", "access-control"]
|
|
497
497
|
},
|
|
498
498
|
"S003": {
|
|
499
|
-
"name": "
|
|
500
|
-
"description": "
|
|
499
|
+
"name": "Open Redirect Protection",
|
|
500
|
+
"description": "URL redirects must validate against an allow list to prevent open redirect vulnerabilities",
|
|
501
501
|
"category": "security",
|
|
502
502
|
"severity": "error",
|
|
503
503
|
"languages": ["typescript", "javascript"],
|
|
504
|
-
"analyzer": "
|
|
505
|
-
"
|
|
504
|
+
"analyzer": "./rules/security/S003_open_redirect_protection/analyzer.js",
|
|
505
|
+
"config": "./rules/security/S003_open_redirect_protection/config.json",
|
|
506
|
+
"version": "1.0.0",
|
|
507
|
+
"status": "stable",
|
|
508
|
+
"tags": ["security", "owasp", "injection", "open-redirect", "phishing", "url-validation"],
|
|
509
|
+
"strategy": {
|
|
510
|
+
"preferred": "heuristic",
|
|
511
|
+
"fallbacks": ["heuristic"],
|
|
512
|
+
"accuracy": {
|
|
513
|
+
"heuristic": 95
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
"engineMappings": {
|
|
517
|
+
"heuristic": ["rules/security/S003_open_redirect_protection/analyzer.js"]
|
|
518
|
+
},
|
|
519
|
+
"metadata": {
|
|
520
|
+
"owaspCategory": "A03:2021 - Injection",
|
|
521
|
+
"cweId": "CWE-601",
|
|
522
|
+
"frameworks": ["Express", "NestJS", "Next.js", "Nuxt.js", "Spring Boot"],
|
|
523
|
+
"detectionPatterns": 28,
|
|
524
|
+
"testCases": 118
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
"S004": {
|
|
528
|
+
"name": "Sensitive Data Logging Protection",
|
|
529
|
+
"description": "Prevent logging of sensitive information like passwords, tokens, and payment data without proper redaction",
|
|
530
|
+
"category": "security",
|
|
531
|
+
"severity": "warning",
|
|
532
|
+
"languages": ["typescript", "javascript"],
|
|
533
|
+
"analyzer": "./rules/security/S004_sensitive_data_logging/analyzer.js",
|
|
534
|
+
"config": "./rules/security/S004_sensitive_data_logging/config.json",
|
|
506
535
|
"version": "1.0.0",
|
|
507
536
|
"status": "stable",
|
|
508
|
-
"tags": ["security", "
|
|
537
|
+
"tags": ["security", "owasp", "logging", "sensitive-data", "pii", "credentials", "data-exposure"],
|
|
538
|
+
"strategy": {
|
|
539
|
+
"preferred": "heuristic",
|
|
540
|
+
"fallbacks": ["heuristic"],
|
|
541
|
+
"accuracy": {
|
|
542
|
+
"heuristic": 90
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
"engineMappings": {
|
|
546
|
+
"heuristic": ["rules/security/S004_sensitive_data_logging/analyzer.js"]
|
|
547
|
+
},
|
|
548
|
+
"metadata": {
|
|
549
|
+
"owaspCategory": "A09:2021 - Security Logging and Monitoring Failures",
|
|
550
|
+
"cweId": "CWE-532",
|
|
551
|
+
"frameworks": ["Express", "NestJS", "Next.js", "Nuxt.js", "Spring Boot", "Winston", "Pino", "Bunyan"],
|
|
552
|
+
"detectionPatterns": 90,
|
|
553
|
+
"testCases": 45
|
|
554
|
+
}
|
|
509
555
|
},
|
|
510
556
|
"S005": {
|
|
511
557
|
"name": "No Origin Header Authentication",
|
|
@@ -636,16 +682,34 @@
|
|
|
636
682
|
"tags": ["security", "uuid", "random"]
|
|
637
683
|
},
|
|
638
684
|
"S012": {
|
|
639
|
-
"name": "
|
|
640
|
-
"description": "
|
|
685
|
+
"name": "Hardcoded Secrets Protection",
|
|
686
|
+
"description": "Detects hardcoded secrets, API keys, passwords, tokens, and credentials in source code to prevent accidental exposure through version control",
|
|
641
687
|
"category": "security",
|
|
642
688
|
"severity": "error",
|
|
643
689
|
"languages": ["typescript", "javascript"],
|
|
644
|
-
"analyzer": "
|
|
645
|
-
"
|
|
690
|
+
"analyzer": "./rules/security/S012_hardcoded_secrets/analyzer.js",
|
|
691
|
+
"config": "./rules/security/S012_hardcoded_secrets/config.json",
|
|
646
692
|
"version": "1.0.0",
|
|
647
693
|
"status": "stable",
|
|
648
|
-
"tags": ["security", "secrets", "hardcoded"]
|
|
694
|
+
"tags": ["security", "owasp", "secrets", "credentials", "cryptographic-failures", "hardcoded-secrets", "api-keys", "passwords", "tokens"],
|
|
695
|
+
"strategy": {
|
|
696
|
+
"preferred": "heuristic",
|
|
697
|
+
"fallbacks": ["heuristic"],
|
|
698
|
+
"accuracy": {
|
|
699
|
+
"heuristic": 92
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
"engineMappings": {
|
|
703
|
+
"heuristic": ["rules/security/S012_hardcoded_secrets/analyzer.js"]
|
|
704
|
+
},
|
|
705
|
+
"metadata": {
|
|
706
|
+
"owaspCategory": "A02:2021 - Cryptographic Failures",
|
|
707
|
+
"cweId": "CWE-798",
|
|
708
|
+
"frameworks": ["Node.js", "Express", "NestJS", "Next.js", "React", "Vue", "Angular"],
|
|
709
|
+
"secretTypes": ["API Keys", "Passwords", "Access Tokens", "Private Keys", "JWT Secrets", "Database Credentials", "OAuth Secrets", "AWS Keys", "GitHub Tokens", "Slack Tokens"],
|
|
710
|
+
"detectionPatterns": 50,
|
|
711
|
+
"testCases": 30
|
|
712
|
+
}
|
|
649
713
|
},
|
|
650
714
|
"S013": {
|
|
651
715
|
"name": "Verify TLS Connection",
|
|
@@ -736,16 +800,34 @@
|
|
|
736
800
|
"tags": ["security", "validation", "input"]
|
|
737
801
|
},
|
|
738
802
|
"S019": {
|
|
739
|
-
"name": "
|
|
740
|
-
"description": "
|
|
803
|
+
"name": "SMTP Injection Protection",
|
|
804
|
+
"description": "Detects potential SMTP/IMAP injection vulnerabilities by identifying unsanitized user input in email fields and direct SMTP protocol manipulation",
|
|
741
805
|
"category": "security",
|
|
742
806
|
"severity": "error",
|
|
743
807
|
"languages": ["typescript", "javascript"],
|
|
744
|
-
"analyzer": "
|
|
745
|
-
"
|
|
808
|
+
"analyzer": "./rules/security/S019_smtp_injection_protection/analyzer.js",
|
|
809
|
+
"config": "./rules/security/S019_smtp_injection_protection/config.json",
|
|
746
810
|
"version": "1.0.0",
|
|
747
811
|
"status": "stable",
|
|
748
|
-
"tags": ["security", "
|
|
812
|
+
"tags": ["security", "owasp", "injection", "smtp", "email", "crlf"],
|
|
813
|
+
"strategy": {
|
|
814
|
+
"preferred": "heuristic",
|
|
815
|
+
"fallbacks": ["heuristic"],
|
|
816
|
+
"accuracy": {
|
|
817
|
+
"heuristic": 90
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
"engineMappings": {
|
|
821
|
+
"heuristic": ["rules/security/S019_smtp_injection_protection/analyzer.js"]
|
|
822
|
+
},
|
|
823
|
+
"metadata": {
|
|
824
|
+
"owaspCategory": "A03:2021 - Injection",
|
|
825
|
+
"cweId": "CWE-93, CWE-144",
|
|
826
|
+
"frameworks": ["Node.js", "Express", "NestJS", "Next.js"],
|
|
827
|
+
"emailLibraries": ["nodemailer", "sendgrid", "mailgun", "aws-ses", "postmark"],
|
|
828
|
+
"detectionTypes": ["Unsanitized email fields", "SMTP command injection", "CRLF injection"],
|
|
829
|
+
"testCases": 40
|
|
830
|
+
}
|
|
749
831
|
},
|
|
750
832
|
"S020": {
|
|
751
833
|
"name": "Avoid using eval() or executing dynamic code",
|
|
@@ -1156,7 +1238,8 @@
|
|
|
1156
1238
|
"category": "security",
|
|
1157
1239
|
"severity": "error",
|
|
1158
1240
|
"languages": ["typescript", "javascript"],
|
|
1159
|
-
"analyzer": "
|
|
1241
|
+
"analyzer": "./rules/security/S042_require_re_authentication_for_long_lived/analyzer.js",
|
|
1242
|
+
"config": "./rules/security/S042_require_re_authentication_for_long_lived/config.json",
|
|
1160
1243
|
"eslintRule": "custom/typescript_s042",
|
|
1161
1244
|
"version": "1.0.0",
|
|
1162
1245
|
"status": "stable",
|
|
@@ -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
|
+
};
|
package/core/cli-program.js
CHANGED
|
@@ -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
|
|
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 #
|
|
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
|
|
142
|
-
$ sunlint --all --input=src --github-annotate=all #
|
|
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
|
-
|
|
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
|
|
928
|
-
|
|
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
|
|
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
|
+
};
|