devkit-quality 0.1.0
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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/devkit.js +3 -0
- package/dist/ast/comments.js +28 -0
- package/dist/ast/parse.js +56 -0
- package/dist/ast/walk.js +69 -0
- package/dist/cli.js +167 -0
- package/dist/cliLogic.js +36 -0
- package/dist/config.js +57 -0
- package/dist/context.js +2 -0
- package/dist/discovery.js +99 -0
- package/dist/finding.js +20 -0
- package/dist/glob.js +35 -0
- package/dist/ignore.js +28 -0
- package/dist/moduleGraph.js +311 -0
- package/dist/moduleResolution.js +64 -0
- package/dist/reporters.js +258 -0
- package/dist/rules/architecture.js +57 -0
- package/dist/rules/complexity.js +230 -0
- package/dist/rules/deadCode.js +252 -0
- package/dist/rules/dependencies.js +137 -0
- package/dist/rules/duplication.js +138 -0
- package/dist/rules/errorHandling.js +120 -0
- package/dist/rules/hygiene.js +102 -0
- package/dist/rules/javascript.js +69 -0
- package/dist/rules/redundancy.js +75 -0
- package/dist/rules/secretPatterns.js +24 -0
- package/dist/rules/security.js +318 -0
- package/dist/rules/typescript.js +124 -0
- package/dist/rules.js +546 -0
- package/dist/scanner.js +150 -0
- package/dist/scoring.js +68 -0
- package/dist/suppressions.js +21 -0
- package/dist/terminal.js +135 -0
- package/dist/types.js +2 -0
- package/package.json +46 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatTerminal = formatTerminal;
|
|
4
|
+
exports.formatJson = formatJson;
|
|
5
|
+
exports.formatMarkdown = formatMarkdown;
|
|
6
|
+
exports.formatSarif = formatSarif;
|
|
7
|
+
exports.formatMetrics = formatMetrics;
|
|
8
|
+
exports.formatBaselineCompare = formatBaselineCompare;
|
|
9
|
+
const rules_1 = require("./rules");
|
|
10
|
+
const scoring_1 = require("./scoring");
|
|
11
|
+
const terminal_1 = require("./terminal");
|
|
12
|
+
const CATEGORY_LABELS = {
|
|
13
|
+
deadCode: 'Dead Code',
|
|
14
|
+
dependencies: 'Dependencies',
|
|
15
|
+
complexity: 'Complexity',
|
|
16
|
+
duplication: 'Duplication',
|
|
17
|
+
redundantLogic: 'Redundant Logic',
|
|
18
|
+
errorHandling: 'Error Handling',
|
|
19
|
+
typescript: 'Type Safety',
|
|
20
|
+
architecture: 'Architecture',
|
|
21
|
+
hygiene: 'Hygiene',
|
|
22
|
+
security: 'Security'
|
|
23
|
+
};
|
|
24
|
+
function humanizeCategory(category) {
|
|
25
|
+
return CATEGORY_LABELS[category] ?? category;
|
|
26
|
+
}
|
|
27
|
+
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
28
|
+
function severityRank(severity) {
|
|
29
|
+
return SEVERITY_RANK[severity] ?? 0;
|
|
30
|
+
}
|
|
31
|
+
const MAX_DETAILED_FINDINGS = 6;
|
|
32
|
+
function formatTerminal(summary) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
lines.push(terminal_1.color.bold('DevKit Repository Scan'));
|
|
35
|
+
lines.push(terminal_1.color.dim(summary.repository));
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push(` ${terminal_1.color.bold(summary.score.toFixed(1))}${terminal_1.color.dim('/10')} ${(0, terminal_1.scoreColor)(summary.score, (0, terminal_1.scoreLabel)(summary.score))}`);
|
|
38
|
+
lines.push(` ${(0, terminal_1.renderScoreBar)(summary.score)}`);
|
|
39
|
+
lines.push('');
|
|
40
|
+
const categoryEntries = Object.entries(summary.categoryScores);
|
|
41
|
+
const nameWidth = Math.max(...categoryEntries.map(([category]) => humanizeCategory(category).length)) + 2;
|
|
42
|
+
for (const [category, score] of categoryEntries) {
|
|
43
|
+
const label = (0, terminal_1.pad)(humanizeCategory(category), nameWidth);
|
|
44
|
+
const scoreText = (0, terminal_1.padStart)(score.toFixed(1), 4);
|
|
45
|
+
lines.push(` ${terminal_1.color.dim(label)}${(0, terminal_1.scoreColor)(score, scoreText)}`);
|
|
46
|
+
}
|
|
47
|
+
const securityLabel = (0, terminal_1.pad)('Security', nameWidth);
|
|
48
|
+
const securityScoreText = (0, terminal_1.padStart)(summary.securityScore.toFixed(1), 4);
|
|
49
|
+
lines.push(` ${terminal_1.color.dim(securityLabel)}${(0, terminal_1.scoreColor)(summary.securityScore, securityScoreText)} ${terminal_1.color.dim('(reported separately)')}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(terminal_1.color.dim('─'.repeat(50)));
|
|
52
|
+
if (summary.findings.length === 0) {
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(terminal_1.color.green('No findings. The repository looks clean.'));
|
|
55
|
+
lines.push('');
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
const bySeverityCount = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
|
|
59
|
+
for (const finding of summary.findings) {
|
|
60
|
+
bySeverityCount[finding.severity] += 1;
|
|
61
|
+
}
|
|
62
|
+
const severitySummary = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
|
|
63
|
+
.filter((severity) => bySeverityCount[severity] > 0)
|
|
64
|
+
.map((severity) => (0, terminal_1.severityColor)(severity, `${bySeverityCount[severity]} ${severity.toLowerCase()}`))
|
|
65
|
+
.join(terminal_1.color.dim(' · '));
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(` ${terminal_1.color.bold(String(summary.findings.length))} findings ${terminal_1.color.dim('(')}${severitySummary}${terminal_1.color.dim(')')}`);
|
|
68
|
+
const findingsByCategory = new Map();
|
|
69
|
+
for (const finding of summary.findings) {
|
|
70
|
+
if (!findingsByCategory.has(finding.category))
|
|
71
|
+
findingsByCategory.set(finding.category, []);
|
|
72
|
+
findingsByCategory.get(finding.category).push(finding);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
const categoryCountWidth = Math.max(...[...findingsByCategory.keys()].map((category) => humanizeCategory(category).length)) + 2;
|
|
76
|
+
for (const [category, categoryFindings] of [...findingsByCategory.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
77
|
+
const worst = categoryFindings.reduce((max, finding) => (severityRank(finding.severity) > severityRank(max.severity) ? finding : max));
|
|
78
|
+
lines.push(` ${(0, terminal_1.pad)(humanizeCategory(category), categoryCountWidth)} ${terminal_1.color.dim('›')} ${(0, terminal_1.severityColor)(worst.severity, `${categoryFindings.length} finding${categoryFindings.length === 1 ? '' : 's'}`)}`);
|
|
79
|
+
}
|
|
80
|
+
const severityRankedFindings = [...summary.findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
|
|
81
|
+
// One representative per category (its worst finding) so the detail section reads as a
|
|
82
|
+
// scannable cross-section of the repository rather than six variations of the same rule.
|
|
83
|
+
const representativeByCategory = new Map();
|
|
84
|
+
for (const finding of severityRankedFindings) {
|
|
85
|
+
if (!representativeByCategory.has(finding.category)) {
|
|
86
|
+
representativeByCategory.set(finding.category, finding);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const topFindings = [...representativeByCategory.values()].slice(0, MAX_DETAILED_FINDINGS);
|
|
90
|
+
if (topFindings.length > 0) {
|
|
91
|
+
lines.push('');
|
|
92
|
+
lines.push(terminal_1.color.dim('─'.repeat(50)));
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(terminal_1.color.bold('Top issues'));
|
|
95
|
+
}
|
|
96
|
+
for (const finding of topFindings) {
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(` ${terminal_1.color.dim(`${humanizeCategory(finding.category)}:`)} ${(0, terminal_1.severityColor)(finding.severity, finding.message)} ${terminal_1.color.dim(`[${finding.ruleId}]`)}`);
|
|
99
|
+
lines.push(` ${finding.description}`);
|
|
100
|
+
lines.push(` ${terminal_1.color.dim('→')} ${finding.suggestion}`);
|
|
101
|
+
lines.push(` ${terminal_1.color.cyan(`${finding.file}:${finding.line}`)}`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
for (const frameLine of (0, terminal_1.renderCodeFrame)(summary.repository, finding.file, finding.line, finding.column, finding.severity)) {
|
|
104
|
+
lines.push(frameLine);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const remaining = summary.findings.length - topFindings.length;
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(terminal_1.color.dim('─'.repeat(50)));
|
|
110
|
+
lines.push('');
|
|
111
|
+
if (remaining > 0) {
|
|
112
|
+
lines.push(terminal_1.color.dim(` + ${remaining} more finding${remaining === 1 ? '' : 's'} not shown above.`));
|
|
113
|
+
}
|
|
114
|
+
lines.push(` ${terminal_1.color.dim('devkit scan --format json')} full machine-readable output`);
|
|
115
|
+
lines.push(` ${terminal_1.color.dim('devkit scan --category <name>')} focus on one category`);
|
|
116
|
+
lines.push(` ${terminal_1.color.dim('devkit explain <RULE_ID>')} why a rule exists and how to fix it`);
|
|
117
|
+
lines.push(` ${terminal_1.color.dim('devkit fix')} preview findings marked safe to fix`);
|
|
118
|
+
lines.push('');
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
function formatJson(summary) {
|
|
122
|
+
return JSON.stringify(summary, null, 2);
|
|
123
|
+
}
|
|
124
|
+
function formatMarkdown(summary) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push('# DevKit Scan Report');
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`- Repository: ${summary.repository}`);
|
|
129
|
+
lines.push(`- Overall score: ${summary.score.toFixed(1)} / 10`);
|
|
130
|
+
lines.push(`- Security score (reported separately): ${summary.securityScore.toFixed(1)} / 10`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('## Category scores');
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push('| Category | Score |');
|
|
135
|
+
lines.push('| --- | ---: |');
|
|
136
|
+
for (const [category, score] of Object.entries(summary.categoryScores)) {
|
|
137
|
+
lines.push(`| ${humanizeCategory(category)} | ${score.toFixed(1)} |`);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push('## Main deductions');
|
|
141
|
+
lines.push('');
|
|
142
|
+
const deductions = (0, scoring_1.summarizeTopDeductions)(summary.findings);
|
|
143
|
+
if (deductions.length === 0) {
|
|
144
|
+
lines.push('No deductions.');
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const deduction of deductions) {
|
|
148
|
+
lines.push(`- ${deduction.count} ${deduction.ruleId} finding(s) (${humanizeCategory(deduction.category)})`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push('## Findings');
|
|
153
|
+
lines.push('');
|
|
154
|
+
if (summary.findings.length === 0) {
|
|
155
|
+
lines.push('No findings.');
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
for (const finding of summary.findings) {
|
|
159
|
+
lines.push(`- ${finding.ruleId}: ${finding.message} (${finding.severity}) in ${finding.file}:${finding.line}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
function formatSarif(summary) {
|
|
165
|
+
const results = summary.findings.map((finding) => ({
|
|
166
|
+
ruleId: finding.ruleId,
|
|
167
|
+
level: finding.severity === 'CRITICAL' || finding.severity === 'HIGH' ? 'error' : finding.severity === 'MEDIUM' ? 'warning' : 'note',
|
|
168
|
+
message: {
|
|
169
|
+
text: finding.message
|
|
170
|
+
},
|
|
171
|
+
locations: [
|
|
172
|
+
{
|
|
173
|
+
physicalLocation: {
|
|
174
|
+
artifactLocation: {
|
|
175
|
+
uri: finding.file
|
|
176
|
+
},
|
|
177
|
+
region: {
|
|
178
|
+
startLine: finding.line,
|
|
179
|
+
startColumn: finding.column
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}));
|
|
185
|
+
const sarifRules = (0, rules_1.listRules)().map((rule) => ({
|
|
186
|
+
id: rule.id,
|
|
187
|
+
name: rule.title,
|
|
188
|
+
shortDescription: { text: rule.title },
|
|
189
|
+
fullDescription: { text: rule.description },
|
|
190
|
+
helpUri: '',
|
|
191
|
+
properties: {
|
|
192
|
+
category: rule.category,
|
|
193
|
+
severity: rule.severity,
|
|
194
|
+
confidence: rule.confidence
|
|
195
|
+
}
|
|
196
|
+
}));
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
version: '2.1.0',
|
|
199
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
200
|
+
runs: [
|
|
201
|
+
{
|
|
202
|
+
tool: {
|
|
203
|
+
driver: {
|
|
204
|
+
name: 'DevKit',
|
|
205
|
+
rules: sarifRules
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
results
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
}, null, 2);
|
|
212
|
+
}
|
|
213
|
+
function formatMetrics(summary) {
|
|
214
|
+
const metrics = summary.metrics;
|
|
215
|
+
const lines = [];
|
|
216
|
+
lines.push(terminal_1.color.bold('Repository metrics'));
|
|
217
|
+
lines.push(terminal_1.color.dim(summary.repository));
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(` totalFiles ${terminal_1.color.bold(String(metrics.totalFiles))}`);
|
|
220
|
+
lines.push(` sourceFiles ${metrics.sourceFiles}`);
|
|
221
|
+
lines.push(` testFiles ${metrics.testFiles}`);
|
|
222
|
+
lines.push(` totalLOC ${metrics.totalLOC}`);
|
|
223
|
+
lines.push(` sourceLOC ${metrics.sourceLOC}`);
|
|
224
|
+
lines.push(` testLOC ${metrics.testLOC}`);
|
|
225
|
+
lines.push(` commentLOC ${metrics.commentLOC}`);
|
|
226
|
+
lines.push(` functionCount ${metrics.functionCount}`);
|
|
227
|
+
lines.push(` classCount ${metrics.classCount}`);
|
|
228
|
+
lines.push(` dependencyCount ${metrics.dependencyCount}`);
|
|
229
|
+
lines.push(` averageFunctionSize ${metrics.averageFunctionSize.toFixed(1)}`);
|
|
230
|
+
lines.push(` duplicationPercentage ${metrics.duplicationPercentage.toFixed(1)}%`);
|
|
231
|
+
lines.push(` deadCodePercentage ${metrics.deadCodePercentage.toFixed(1)}%`);
|
|
232
|
+
lines.push(` testToSourceRatio ${metrics.testToSourceRatio.toFixed(2)}`);
|
|
233
|
+
if (metrics.largestFunctions.length > 0) {
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push(terminal_1.color.bold('Largest functions'));
|
|
236
|
+
for (const fn of metrics.largestFunctions) {
|
|
237
|
+
lines.push(` ${terminal_1.color.cyan(fn.name)} ${terminal_1.color.dim(`(${fn.file})`)} - ${fn.size} lines`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (metrics.largestFiles.length > 0) {
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push(terminal_1.color.bold('Largest files'));
|
|
243
|
+
for (const file of metrics.largestFiles) {
|
|
244
|
+
lines.push(` ${terminal_1.color.cyan(file.file)} - ${file.loc} lines`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
function formatBaselineCompare(previousScore, currentScore) {
|
|
250
|
+
const delta = Number((currentScore - previousScore).toFixed(1));
|
|
251
|
+
const lines = [];
|
|
252
|
+
lines.push(terminal_1.color.bold('Baseline compare'));
|
|
253
|
+
lines.push(` Previous score: ${previousScore.toFixed(1)}`);
|
|
254
|
+
lines.push(` Current score: ${currentScore.toFixed(1)}`);
|
|
255
|
+
const deltaText = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}`;
|
|
256
|
+
lines.push(` Regression: ${delta < 0 ? terminal_1.color.red(deltaText) : terminal_1.color.green(deltaText)}`);
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runArchitectureRules = runArchitectureRules;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const finding_1 = require("../finding");
|
|
9
|
+
const config_1 = require("../config");
|
|
10
|
+
const glob_1 = require("../glob");
|
|
11
|
+
function layerForFile(relativePath, layers) {
|
|
12
|
+
for (const [layerName, layer] of Object.entries(layers)) {
|
|
13
|
+
if ((0, glob_1.matchesAnyGlob)(relativePath, layer.match)) {
|
|
14
|
+
return layerName;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function runArchitectureRules(context) {
|
|
20
|
+
if (!(0, config_1.isRuleEnabled)(context.config, 'ARCH001'))
|
|
21
|
+
return [];
|
|
22
|
+
const layers = context.config.architecture?.layers;
|
|
23
|
+
if (!layers || Object.keys(layers).length === 0)
|
|
24
|
+
return [];
|
|
25
|
+
const findings = [];
|
|
26
|
+
const { moduleGraph, projectRoot } = context;
|
|
27
|
+
for (const [file, targets] of moduleGraph.edges) {
|
|
28
|
+
const sourceRelative = node_path_1.default.relative(projectRoot, file).replace(/\\/g, '/');
|
|
29
|
+
const sourceLayer = layerForFile(sourceRelative, layers);
|
|
30
|
+
if (!sourceLayer)
|
|
31
|
+
continue;
|
|
32
|
+
const forbidden = layers[sourceLayer].cannotImport ?? [];
|
|
33
|
+
if (forbidden.length === 0)
|
|
34
|
+
continue;
|
|
35
|
+
for (const target of targets) {
|
|
36
|
+
const targetRelative = node_path_1.default.relative(projectRoot, target).replace(/\\/g, '/');
|
|
37
|
+
const targetLayer = layerForFile(targetRelative, layers);
|
|
38
|
+
if (!targetLayer || !forbidden.includes(targetLayer))
|
|
39
|
+
continue;
|
|
40
|
+
findings.push((0, finding_1.buildFinding)({
|
|
41
|
+
ruleId: 'ARCH001',
|
|
42
|
+
category: 'architecture',
|
|
43
|
+
severity: 'HIGH',
|
|
44
|
+
confidence: 'CERTAIN',
|
|
45
|
+
file: sourceRelative,
|
|
46
|
+
line: 1,
|
|
47
|
+
column: 1,
|
|
48
|
+
message: 'Forbidden layer import',
|
|
49
|
+
description: `Layer "${sourceLayer}" is configured to never import from layer "${targetLayer}".`,
|
|
50
|
+
evidence: `${sourceRelative} -> ${targetRelative}`,
|
|
51
|
+
suggestion: 'Remove the import or restructure the dependency to respect the architecture boundary.',
|
|
52
|
+
fixAvailable: false
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.collectFunctionMetrics = collectFunctionMetrics;
|
|
7
|
+
exports.runComplexityRules = runComplexityRules;
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
10
|
+
const finding_1 = require("../finding");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const walk_1 = require("../ast/walk");
|
|
13
|
+
const DEFAULT_THRESHOLDS = {
|
|
14
|
+
COMPLEX001: 10,
|
|
15
|
+
COMPLEX002: 15,
|
|
16
|
+
COMPLEX003: 100,
|
|
17
|
+
COMPLEX004: 5,
|
|
18
|
+
COMPLEX005: 4
|
|
19
|
+
};
|
|
20
|
+
function severityForOverage(value, max) {
|
|
21
|
+
return value >= max * 2 ? 'HIGH' : 'MEDIUM';
|
|
22
|
+
}
|
|
23
|
+
function computeFunctionMetrics(node, sourceFile) {
|
|
24
|
+
let cyclomatic = 1;
|
|
25
|
+
let cognitive = 0;
|
|
26
|
+
let statementCount = 0;
|
|
27
|
+
let returnCount = 0;
|
|
28
|
+
let maxNesting = 0;
|
|
29
|
+
const visit = (current, nestingLevel, isElseIfChain) => {
|
|
30
|
+
if (current !== node && (0, walk_1.isFunctionLike)(current)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (typescript_1.default.isStatement(current)) {
|
|
34
|
+
statementCount += 1;
|
|
35
|
+
}
|
|
36
|
+
if (typescript_1.default.isReturnStatement(current)) {
|
|
37
|
+
returnCount += 1;
|
|
38
|
+
}
|
|
39
|
+
if (typescript_1.default.isIfStatement(current)) {
|
|
40
|
+
maxNesting = Math.max(maxNesting, nestingLevel + 1);
|
|
41
|
+
cyclomatic += 1;
|
|
42
|
+
cognitive += isElseIfChain ? 1 : 1 + nestingLevel;
|
|
43
|
+
visit(current.thenStatement, nestingLevel + 1, false);
|
|
44
|
+
if (current.elseStatement) {
|
|
45
|
+
const elseIsIf = typescript_1.default.isIfStatement(current.elseStatement);
|
|
46
|
+
visit(current.elseStatement, nestingLevel + (elseIsIf ? 0 : 1), elseIsIf);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (typescript_1.default.isForStatement(current) ||
|
|
51
|
+
typescript_1.default.isForInStatement(current) ||
|
|
52
|
+
typescript_1.default.isForOfStatement(current) ||
|
|
53
|
+
typescript_1.default.isWhileStatement(current) ||
|
|
54
|
+
typescript_1.default.isDoStatement(current)) {
|
|
55
|
+
maxNesting = Math.max(maxNesting, nestingLevel + 1);
|
|
56
|
+
cyclomatic += 1;
|
|
57
|
+
cognitive += 1 + nestingLevel;
|
|
58
|
+
typescript_1.default.forEachChild(current, (child) => visit(child, nestingLevel + 1, false));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typescript_1.default.isSwitchStatement(current)) {
|
|
62
|
+
maxNesting = Math.max(maxNesting, nestingLevel + 1);
|
|
63
|
+
cognitive += 1 + nestingLevel;
|
|
64
|
+
for (const clause of current.caseBlock.clauses) {
|
|
65
|
+
if (typescript_1.default.isCaseClause(clause)) {
|
|
66
|
+
cyclomatic += 1;
|
|
67
|
+
}
|
|
68
|
+
for (const statement of clause.statements) {
|
|
69
|
+
visit(statement, nestingLevel + 1, false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (typescript_1.default.isCatchClause(current)) {
|
|
75
|
+
maxNesting = Math.max(maxNesting, nestingLevel + 1);
|
|
76
|
+
cyclomatic += 1;
|
|
77
|
+
cognitive += 1 + nestingLevel;
|
|
78
|
+
typescript_1.default.forEachChild(current, (child) => visit(child, nestingLevel + 1, false));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (typescript_1.default.isConditionalExpression(current)) {
|
|
82
|
+
cyclomatic += 1;
|
|
83
|
+
cognitive += 1 + nestingLevel;
|
|
84
|
+
visit(current.condition, nestingLevel, false);
|
|
85
|
+
visit(current.whenTrue, nestingLevel + 1, false);
|
|
86
|
+
visit(current.whenFalse, nestingLevel + 1, false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (typescript_1.default.isBinaryExpression(current)) {
|
|
90
|
+
const operator = current.operatorToken.kind;
|
|
91
|
+
const isLogical = operator === typescript_1.default.SyntaxKind.AmpersandAmpersandToken ||
|
|
92
|
+
operator === typescript_1.default.SyntaxKind.BarBarToken ||
|
|
93
|
+
operator === typescript_1.default.SyntaxKind.QuestionQuestionToken;
|
|
94
|
+
if (isLogical) {
|
|
95
|
+
cyclomatic += 1;
|
|
96
|
+
const parent = current.parent;
|
|
97
|
+
const sameChainAsParent = typescript_1.default.isBinaryExpression(parent) && parent.operatorToken.kind === operator;
|
|
98
|
+
if (!sameChainAsParent) {
|
|
99
|
+
cognitive += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
typescript_1.default.forEachChild(current, (child) => visit(child, nestingLevel, false));
|
|
104
|
+
};
|
|
105
|
+
const body = node.body;
|
|
106
|
+
if (body) {
|
|
107
|
+
visit(body, 0, false);
|
|
108
|
+
}
|
|
109
|
+
const start = (0, walk_1.lineAndColumn)(sourceFile, node.getStart()).line;
|
|
110
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
111
|
+
return {
|
|
112
|
+
line: start,
|
|
113
|
+
lines: end - start + 1,
|
|
114
|
+
cyclomatic,
|
|
115
|
+
cognitive,
|
|
116
|
+
parameters: node.parameters.length,
|
|
117
|
+
nestingDepth: maxNesting,
|
|
118
|
+
statementCount,
|
|
119
|
+
returnCount
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function collectFunctionMetrics(context) {
|
|
123
|
+
const metrics = [];
|
|
124
|
+
for (const file of context.files) {
|
|
125
|
+
const sourceFile = context.program.getSourceFile(file);
|
|
126
|
+
if (!sourceFile)
|
|
127
|
+
continue;
|
|
128
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
129
|
+
const visit = (node) => {
|
|
130
|
+
if ((0, walk_1.isFunctionLike)(node) && node.body) {
|
|
131
|
+
const computed = computeFunctionMetrics(node, sourceFile);
|
|
132
|
+
metrics.push({ name: (0, walk_1.getFunctionName)(node), file: relativePath, ...computed });
|
|
133
|
+
}
|
|
134
|
+
typescript_1.default.forEachChild(node, visit);
|
|
135
|
+
};
|
|
136
|
+
visit(sourceFile);
|
|
137
|
+
}
|
|
138
|
+
return metrics;
|
|
139
|
+
}
|
|
140
|
+
function runComplexityRules(context, functionMetrics) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
const cyclomaticMax = (0, config_1.getRuleThreshold)(context.config, 'COMPLEX001', DEFAULT_THRESHOLDS.COMPLEX001);
|
|
143
|
+
const cognitiveMax = (0, config_1.getRuleThreshold)(context.config, 'COMPLEX002', DEFAULT_THRESHOLDS.COMPLEX002);
|
|
144
|
+
const linesMax = (0, config_1.getRuleThreshold)(context.config, 'COMPLEX003', DEFAULT_THRESHOLDS.COMPLEX003);
|
|
145
|
+
const paramsMax = (0, config_1.getRuleThreshold)(context.config, 'COMPLEX004', DEFAULT_THRESHOLDS.COMPLEX004);
|
|
146
|
+
const nestingMax = (0, config_1.getRuleThreshold)(context.config, 'COMPLEX005', DEFAULT_THRESHOLDS.COMPLEX005);
|
|
147
|
+
for (const metric of functionMetrics) {
|
|
148
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'COMPLEX001') && metric.cyclomatic > cyclomaticMax) {
|
|
149
|
+
findings.push((0, finding_1.buildFinding)({
|
|
150
|
+
ruleId: 'COMPLEX001',
|
|
151
|
+
category: 'complexity',
|
|
152
|
+
severity: severityForOverage(metric.cyclomatic, cyclomaticMax),
|
|
153
|
+
confidence: 'HIGH',
|
|
154
|
+
file: metric.file,
|
|
155
|
+
line: metric.line,
|
|
156
|
+
column: 1,
|
|
157
|
+
message: 'High cyclomatic complexity function',
|
|
158
|
+
description: `Function "${metric.name}" has cyclomatic complexity ${metric.cyclomatic} (max ${cyclomaticMax}).`,
|
|
159
|
+
evidence: `cyclomatic=${metric.cyclomatic}`,
|
|
160
|
+
suggestion: 'Split the function into smaller units or reduce branching.',
|
|
161
|
+
fixAvailable: false
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'COMPLEX002') && metric.cognitive > cognitiveMax) {
|
|
165
|
+
findings.push((0, finding_1.buildFinding)({
|
|
166
|
+
ruleId: 'COMPLEX002',
|
|
167
|
+
category: 'complexity',
|
|
168
|
+
severity: severityForOverage(metric.cognitive, cognitiveMax),
|
|
169
|
+
confidence: 'HIGH',
|
|
170
|
+
file: metric.file,
|
|
171
|
+
line: metric.line,
|
|
172
|
+
column: 1,
|
|
173
|
+
message: 'High cognitive complexity function',
|
|
174
|
+
description: `Function "${metric.name}" has cognitive complexity ${metric.cognitive} (max ${cognitiveMax}).`,
|
|
175
|
+
evidence: `cognitive=${metric.cognitive}`,
|
|
176
|
+
suggestion: 'Reduce nesting and simplify control flow.',
|
|
177
|
+
fixAvailable: false
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'COMPLEX003') && metric.lines > linesMax) {
|
|
181
|
+
findings.push((0, finding_1.buildFinding)({
|
|
182
|
+
ruleId: 'COMPLEX003',
|
|
183
|
+
category: 'complexity',
|
|
184
|
+
severity: severityForOverage(metric.lines, linesMax),
|
|
185
|
+
confidence: 'HIGH',
|
|
186
|
+
file: metric.file,
|
|
187
|
+
line: metric.line,
|
|
188
|
+
column: 1,
|
|
189
|
+
message: 'Function too long',
|
|
190
|
+
description: `Function "${metric.name}" spans ${metric.lines} lines (max ${linesMax}).`,
|
|
191
|
+
evidence: `lines=${metric.lines}`,
|
|
192
|
+
suggestion: 'Extract smaller helper functions.',
|
|
193
|
+
fixAvailable: false
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'COMPLEX004') && metric.parameters > paramsMax) {
|
|
197
|
+
findings.push((0, finding_1.buildFinding)({
|
|
198
|
+
ruleId: 'COMPLEX004',
|
|
199
|
+
category: 'complexity',
|
|
200
|
+
severity: severityForOverage(metric.parameters, paramsMax),
|
|
201
|
+
confidence: 'HIGH',
|
|
202
|
+
file: metric.file,
|
|
203
|
+
line: metric.line,
|
|
204
|
+
column: 1,
|
|
205
|
+
message: 'Too many parameters',
|
|
206
|
+
description: `Function "${metric.name}" takes ${metric.parameters} parameters (max ${paramsMax}).`,
|
|
207
|
+
evidence: `parameters=${metric.parameters}`,
|
|
208
|
+
suggestion: 'Group related parameters into an options object.',
|
|
209
|
+
fixAvailable: false
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'COMPLEX005') && metric.nestingDepth > nestingMax) {
|
|
213
|
+
findings.push((0, finding_1.buildFinding)({
|
|
214
|
+
ruleId: 'COMPLEX005',
|
|
215
|
+
category: 'complexity',
|
|
216
|
+
severity: severityForOverage(metric.nestingDepth, nestingMax),
|
|
217
|
+
confidence: 'HIGH',
|
|
218
|
+
file: metric.file,
|
|
219
|
+
line: metric.line,
|
|
220
|
+
column: 1,
|
|
221
|
+
message: 'Excessive nesting depth',
|
|
222
|
+
description: `Function "${metric.name}" nests ${metric.nestingDepth} levels deep (max ${nestingMax}).`,
|
|
223
|
+
evidence: `nestingDepth=${metric.nestingDepth}`,
|
|
224
|
+
suggestion: 'Use early returns or extract nested blocks into their own functions.',
|
|
225
|
+
fixAvailable: false
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
}
|