devkit-quality 0.1.0 → 0.1.1
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/CHANGELOG.md +93 -0
- package/README.md +38 -15
- package/dist/cli.js +71 -49
- package/dist/cliLogic.js +39 -4
- package/dist/discovery.js +175 -17
- package/dist/glob.js +5 -1
- package/dist/ignore.js +14 -5
- package/dist/moduleGraph.js +4 -1
- package/dist/reporters.js +180 -92
- package/dist/rules/deadCode.js +4 -4
- package/dist/rules/dependencies.js +126 -53
- package/dist/rules/duplication.js +24 -9
- package/dist/rules/errorHandling.js +3 -1
- package/dist/rules/hygiene.js +11 -2
- package/dist/rules/security.js +20 -6
- package/dist/scanner.js +90 -27
- package/dist/scoring.js +53 -15
- package/dist/suppressions.js +28 -4
- package/dist/terminal.js +151 -37
- package/package.json +11 -3
|
@@ -12,13 +12,23 @@ const finding_1 = require("../finding");
|
|
|
12
12
|
const config_1 = require("../config");
|
|
13
13
|
const DEFAULT_MIN_TOKENS = 40;
|
|
14
14
|
const WINDOW_STEP = 5;
|
|
15
|
+
function importRanges(sourceFile) {
|
|
16
|
+
return sourceFile.statements
|
|
17
|
+
.filter((statement) => typescript_1.default.isImportDeclaration(statement) || typescript_1.default.isImportEqualsDeclaration(statement) || (typescript_1.default.isExportDeclaration(statement) && !!statement.moduleSpecifier))
|
|
18
|
+
.map((statement) => [statement.getStart(sourceFile), statement.getEnd()]);
|
|
19
|
+
}
|
|
15
20
|
function tokenize(sourceFile, languageVariant) {
|
|
16
21
|
const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.ES2022, true, languageVariant, sourceFile.text);
|
|
22
|
+
// Import headers are near-identical across files by nature (especially tests) and are not
|
|
23
|
+
// extractable logic, so they are left out of clone detection.
|
|
24
|
+
const skipped = importRanges(sourceFile);
|
|
17
25
|
const tokens = [];
|
|
18
26
|
let kind = scanner.scan();
|
|
19
27
|
while (kind !== typescript_1.default.SyntaxKind.EndOfFileToken) {
|
|
20
28
|
const pos = scanner.getTokenStart();
|
|
21
|
-
|
|
29
|
+
if (!skipped.some(([start, end]) => pos >= start && pos < end)) {
|
|
30
|
+
tokens.push({ text: scanner.getTokenText(), line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1, kind });
|
|
31
|
+
}
|
|
22
32
|
kind = scanner.scan();
|
|
23
33
|
}
|
|
24
34
|
return tokens;
|
|
@@ -42,11 +52,14 @@ function severityForSpan(spanTokens, minTokens) {
|
|
|
42
52
|
return 'LOW';
|
|
43
53
|
}
|
|
44
54
|
function mergeRanges(ranges) {
|
|
45
|
-
const sorted = [...ranges].sort((a, b) => a.
|
|
55
|
+
const sorted = [...ranges].sort((a, b) => a.startIndexA - b.startIndexA || a.startIndexB - b.startIndexB);
|
|
46
56
|
const merged = [];
|
|
47
57
|
for (const range of sorted) {
|
|
48
58
|
const last = merged[merged.length - 1];
|
|
49
|
-
|
|
59
|
+
// Consecutive windows of the same clone advance by the same offset on both sides.
|
|
60
|
+
const sameClone = last && range.startIndexA <= last.endIndexA && range.startIndexB - range.startIndexA === last.startIndexB - last.startIndexA;
|
|
61
|
+
if (sameClone) {
|
|
62
|
+
last.endIndexA = Math.max(last.endIndexA, range.endIndexA);
|
|
50
63
|
last.endLineA = Math.max(last.endLineA, range.endLineA);
|
|
51
64
|
last.endLineB = Math.max(last.endLineB, range.endLineB);
|
|
52
65
|
}
|
|
@@ -95,7 +108,8 @@ function runDuplicationRules(context) {
|
|
|
95
108
|
for (let j = i + 1; j < entries.length; j += 1) {
|
|
96
109
|
const a = entries[i];
|
|
97
110
|
const b = entries[j];
|
|
98
|
-
|
|
111
|
+
// Within one file, overlapping windows are repetitive code, not a copy-paste.
|
|
112
|
+
if (a.file === b.file && Math.abs(a.startIndex - b.startIndex) < minTokens)
|
|
99
113
|
continue;
|
|
100
114
|
const key = a.file <= b.file ? `${a.file}::${b.file}` : `${b.file}::${a.file}`;
|
|
101
115
|
const [first, second] = a.file <= b.file ? [a, b] : [b, a];
|
|
@@ -105,7 +119,10 @@ function runDuplicationRules(context) {
|
|
|
105
119
|
startLineA: first.startLine,
|
|
106
120
|
endLineA: first.endLine,
|
|
107
121
|
startLineB: second.startLine,
|
|
108
|
-
endLineB: second.endLine
|
|
122
|
+
endLineB: second.endLine,
|
|
123
|
+
startIndexA: first.startIndex,
|
|
124
|
+
endIndexA: first.startIndex + minTokens,
|
|
125
|
+
startIndexB: second.startIndex
|
|
109
126
|
});
|
|
110
127
|
}
|
|
111
128
|
}
|
|
@@ -115,13 +132,11 @@ function runDuplicationRules(context) {
|
|
|
115
132
|
const [fileA, fileB] = key.split('::');
|
|
116
133
|
const merged = mergeRanges(ranges);
|
|
117
134
|
for (const range of merged) {
|
|
118
|
-
const
|
|
119
|
-
if (spanLines * WINDOW_STEP < minTokens)
|
|
120
|
-
continue;
|
|
135
|
+
const spanTokens = range.endIndexA - range.startIndexA;
|
|
121
136
|
findings.push((0, finding_1.buildFinding)({
|
|
122
137
|
ruleId: 'DUP001',
|
|
123
138
|
category: 'duplication',
|
|
124
|
-
severity: severityForSpan(
|
|
139
|
+
severity: severityForSpan(spanTokens, minTokens),
|
|
125
140
|
confidence: 'MEDIUM',
|
|
126
141
|
file: fileA,
|
|
127
142
|
line: range.startLineA,
|
|
@@ -65,7 +65,9 @@ function runErrorHandlingRules(context) {
|
|
|
65
65
|
const visit = (node) => {
|
|
66
66
|
if (typescript_1.default.isCatchClause(node)) {
|
|
67
67
|
const statements = node.block.statements;
|
|
68
|
-
|
|
68
|
+
// A comment inside the block documents an intentional ignore, which is what the rule asks for.
|
|
69
|
+
const documented = /\/\/|\/\*/.test(node.block.getText());
|
|
70
|
+
if ((0, config_1.isRuleEnabled)(context.config, 'ERR001') && statements.length === 0 && !documented) {
|
|
69
71
|
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
70
72
|
findings.push((0, finding_1.buildFinding)({
|
|
71
73
|
ruleId: 'ERR001', category: 'errorHandling', severity: 'MEDIUM', confidence: 'CERTAIN', file: relativePath, line, column,
|
package/dist/rules/hygiene.js
CHANGED
|
@@ -14,6 +14,10 @@ const walk_1 = require("../ast/walk");
|
|
|
14
14
|
const BACKUP_FILE_PATTERN = /(\.bak|\.orig|\.tmp|~)$/i;
|
|
15
15
|
const ENV_FILE_PATTERN = /^\.env(\..+)?$/i;
|
|
16
16
|
const ENV_SAFE_SUFFIXES = /\.(example|sample|template|dist)$/i;
|
|
17
|
+
// A file that builds a CLI program (imports an argv/command-parsing library) treats console
|
|
18
|
+
// output as its actual product, not a debug leftover. Still flagged, at lower confidence, so it
|
|
19
|
+
// stays visible but does not carry the same weight as a stray console.log in business logic.
|
|
20
|
+
const CLI_LIBRARY_IMPORT = /\bfrom\s+['"](?:commander|yargs|cac|meow|sade|clipanion)['"]|\brequire\(\s*['"](?:commander|yargs|cac|meow|sade|clipanion)['"]\s*\)/;
|
|
17
21
|
function runHygieneRules(context) {
|
|
18
22
|
const findings = [];
|
|
19
23
|
for (const file of context.files) {
|
|
@@ -22,14 +26,19 @@ function runHygieneRules(context) {
|
|
|
22
26
|
if (!sourceFile)
|
|
23
27
|
continue;
|
|
24
28
|
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
29
|
+
const isCliEntryFile = CLI_LIBRARY_IMPORT.test(text);
|
|
25
30
|
const visit = (node) => {
|
|
26
31
|
if ((0, config_1.isRuleEnabled)(context.config, 'HYGIENE001') && typescript_1.default.isCallExpression(node) && typescript_1.default.isPropertyAccessExpression(node.expression) &&
|
|
27
32
|
typescript_1.default.isIdentifier(node.expression.expression) && node.expression.expression.text === 'console' &&
|
|
28
33
|
['log', 'debug', 'warn', 'error'].includes(node.expression.name.text)) {
|
|
29
34
|
const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
|
|
30
35
|
findings.push((0, finding_1.buildFinding)({
|
|
31
|
-
ruleId: 'HYGIENE001', category: 'hygiene', severity: 'LOW', confidence: 'CERTAIN', file: relativePath, line, column,
|
|
32
|
-
message: 'Debug statement found',
|
|
36
|
+
ruleId: 'HYGIENE001', category: 'hygiene', severity: 'LOW', confidence: isCliEntryFile ? 'LOW' : 'CERTAIN', file: relativePath, line, column,
|
|
37
|
+
message: 'Debug statement found',
|
|
38
|
+
description: isCliEntryFile
|
|
39
|
+
? 'Console output remains in source code. This file builds a CLI program, so this may be intended output rather than a debug leftover.'
|
|
40
|
+
: 'Console output remains in source code.',
|
|
41
|
+
evidence: node.getText().slice(0, 160),
|
|
33
42
|
suggestion: 'Remove or gate debug logging before production deployment.', fixAvailable: true
|
|
34
43
|
}));
|
|
35
44
|
}
|
package/dist/rules/security.js
CHANGED
|
@@ -62,24 +62,38 @@ function containsUntrustedInput(expression) {
|
|
|
62
62
|
function hasPathSanitizer(node) {
|
|
63
63
|
return /(?:normalize|sanitize|safePath|allowedPaths|allowlist)\s*\(/i.test(node.getText());
|
|
64
64
|
}
|
|
65
|
+
// Unquoted `key: value` / `KEY=value` assignments as found in .env, YAML, TOML, INI and properties files.
|
|
66
|
+
const CONFIG_SECRET_PATTERN = /\b([A-Za-z0-9_.-]*(?:api[_-]?key|secret|token|passw(?:or)?d|private[_-]?key|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_.-]*)\b["']?\s*[:=]\s*["']?([^\s"',;}]{8,})/i;
|
|
67
|
+
const PLACEHOLDER_VALUE = /example|sample|dummy|placeholder|changeme|your[_-]|xxxx|<[^>]*>|\$\{|\{\{|process\.env|^\*+$/i;
|
|
68
|
+
const DOCUMENTATION_FILE = /\.(?:md|mdx|markdown|rst|txt|adoc)$/i;
|
|
65
69
|
function runSecretScan(context) {
|
|
66
70
|
if (!(0, config_1.isRuleEnabled)(context.config, 'SEC001'))
|
|
67
71
|
return [];
|
|
68
72
|
const findings = [];
|
|
69
|
-
|
|
73
|
+
const sourceFiles = new Set(context.files);
|
|
74
|
+
for (const file of [...context.files, ...context.textFiles]) {
|
|
70
75
|
const text = (0, discovery_1.readFileSafe)(file);
|
|
71
76
|
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
72
77
|
const lines = text.split(/\r\n|\r|\n/);
|
|
73
78
|
const isTestFixture = (0, discovery_1.isTestFile)(relativePath) || /(?:^|\/)(?:fixtures?|__mocks__)(?:\/|$)/i.test(relativePath);
|
|
79
|
+
const isSource = sourceFiles.has(file);
|
|
80
|
+
const isDocumentation = DOCUMENTATION_FILE.test(relativePath);
|
|
74
81
|
for (let i = 0; i < lines.length; i += 1) {
|
|
75
82
|
const providerPattern = secretPatterns_1.SECRET_PATTERNS.find((pattern) => pattern.regex.test(lines[i]));
|
|
76
|
-
|
|
77
|
-
if (providerPattern
|
|
83
|
+
let fallbackMatch = null;
|
|
84
|
+
if (!providerPattern && !isTestFixture && !isDocumentation) {
|
|
85
|
+
const candidate = lines[i].match(isSource ? SECRET_PATTERN : CONFIG_SECRET_PATTERN);
|
|
86
|
+
if (candidate && !PLACEHOLDER_VALUE.test(candidate[2]))
|
|
87
|
+
fallbackMatch = candidate;
|
|
88
|
+
}
|
|
89
|
+
if (providerPattern || (fallbackMatch && (0, secretPatterns_1.looksHighEntropy)(fallbackMatch[2], isSource ? 20 : 16))) {
|
|
90
|
+
// Fixtures and docs legitimately carry example credentials; keep them visible but discounted.
|
|
91
|
+
const discounted = isTestFixture || isDocumentation || /EXAMPLE/.test(lines[i]);
|
|
78
92
|
findings.push((0, finding_1.buildFinding)({
|
|
79
93
|
ruleId: 'SEC001',
|
|
80
94
|
category: 'security',
|
|
81
95
|
severity: 'HIGH',
|
|
82
|
-
confidence: providerPattern ? 'HIGH' : 'MEDIUM',
|
|
96
|
+
confidence: discounted ? 'LOW' : providerPattern ? 'HIGH' : 'MEDIUM',
|
|
83
97
|
file: relativePath,
|
|
84
98
|
line: i + 1,
|
|
85
99
|
column: providerPattern ? lines[i].search(providerPattern.regex) + 1 : lines[i].indexOf(fallbackMatch[0]) + 1,
|
|
@@ -293,8 +307,8 @@ function scanTemplateFilesForUnsafeBindings(context) {
|
|
|
293
307
|
if (!(0, config_1.isRuleEnabled)(context.config, 'SEC004'))
|
|
294
308
|
return [];
|
|
295
309
|
const findings = [];
|
|
296
|
-
for (const file of context.
|
|
297
|
-
if (!/\.(?:vue|html)$/i.test(file))
|
|
310
|
+
for (const file of context.textFiles) {
|
|
311
|
+
if (!/\.(?:vue|html|svelte)$/i.test(file))
|
|
298
312
|
continue;
|
|
299
313
|
const text = (0, discovery_1.readFileSafe)(file);
|
|
300
314
|
const lines = text.split(/\r\n|\r|\n/);
|
package/dist/scanner.js
CHANGED
|
@@ -11,7 +11,6 @@ const discovery_1 = require("./discovery");
|
|
|
11
11
|
const parse_1 = require("./ast/parse");
|
|
12
12
|
const moduleGraph_1 = require("./moduleGraph");
|
|
13
13
|
const config_1 = require("./config");
|
|
14
|
-
const ignore_1 = require("./ignore");
|
|
15
14
|
const complexity_1 = require("./rules/complexity");
|
|
16
15
|
const deadCode_1 = require("./rules/deadCode");
|
|
17
16
|
const dependencies_1 = require("./rules/dependencies");
|
|
@@ -53,29 +52,80 @@ function estimateDuplicatedLines(findings) {
|
|
|
53
52
|
}
|
|
54
53
|
return total;
|
|
55
54
|
}
|
|
56
|
-
function
|
|
55
|
+
function countLanguages(files) {
|
|
56
|
+
const counts = {};
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const base = node_path_1.default.basename(file);
|
|
59
|
+
const key = node_path_1.default.extname(file).toLowerCase() || (base.startsWith('.') ? base : '(none)');
|
|
60
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
61
|
+
}
|
|
62
|
+
return counts;
|
|
63
|
+
}
|
|
64
|
+
const RULE_PHASES = [
|
|
65
|
+
['Dead code', deadCode_1.runDeadCodeRules],
|
|
66
|
+
['Dependencies', dependencies_1.runDependencyRules],
|
|
67
|
+
['Duplication', duplication_1.runDuplicationRules],
|
|
68
|
+
['Error handling', errorHandling_1.runErrorHandlingRules],
|
|
69
|
+
['Redundant logic', redundancy_1.runRedundancyRules],
|
|
70
|
+
['Type safety', (context) => [...(0, typescript_2.runTypeScriptRules)(context), ...(0, javascript_1.runJavaScriptRules)(context)]],
|
|
71
|
+
['Security', security_1.runSecurityRules],
|
|
72
|
+
['Architecture', architecture_1.runArchitectureRules],
|
|
73
|
+
['Hygiene', hygiene_1.runHygieneRules]
|
|
74
|
+
];
|
|
75
|
+
// discovery, program, type-check, module graph, function metrics + rule phases + scoring
|
|
76
|
+
const TOTAL_STEPS = 5 + RULE_PHASES.length + 1;
|
|
77
|
+
function scanRepository(projectRoot, onProgress = () => undefined) {
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
let step = 0;
|
|
80
|
+
const phase = (label, current, total) => {
|
|
81
|
+
onProgress({ step, totalSteps: TOTAL_STEPS, label, current, total });
|
|
82
|
+
};
|
|
83
|
+
const nextPhase = (label) => {
|
|
84
|
+
step += 1;
|
|
85
|
+
phase(label);
|
|
86
|
+
};
|
|
87
|
+
nextPhase('Discovering files');
|
|
57
88
|
const config = (0, config_1.loadConfig)(projectRoot);
|
|
58
|
-
const
|
|
59
|
-
const { files,
|
|
89
|
+
const discovery = (0, discovery_1.discoverFiles)(projectRoot, config);
|
|
90
|
+
const { files, allFiles, textFiles, generatedFiles } = discovery;
|
|
60
91
|
const packageJsonPath = node_path_1.default.join(projectRoot, 'package.json');
|
|
61
|
-
|
|
92
|
+
let packageJson = null;
|
|
93
|
+
if (node_fs_1.default.existsSync(packageJsonPath)) {
|
|
94
|
+
try {
|
|
95
|
+
packageJson = JSON.parse((0, discovery_1.readFileSafe)(packageJsonPath));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
packageJson = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
nextPhase(`Parsing ${files.length} source files`);
|
|
62
102
|
const program = (0, parse_1.createProgram)(files);
|
|
103
|
+
// Type-check every file up front: the program caches diagnostics, so the rule phases that
|
|
104
|
+
// need them are cheap afterwards, and this (the slowest step) can report per-file progress.
|
|
105
|
+
nextPhase('Type-checking');
|
|
106
|
+
files.forEach((file, index) => {
|
|
107
|
+
phase('Type-checking', index + 1, files.length);
|
|
108
|
+
const sourceFile = program.getSourceFile(file);
|
|
109
|
+
if (!sourceFile)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
program.getSemanticDiagnostics(sourceFile);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// A checker crash on one file must not abort the whole scan; rules skip it the same way.
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
nextPhase('Building module graph');
|
|
63
119
|
const moduleGraph = (0, moduleGraph_1.buildModuleGraph)(projectRoot, files, packageJson, config);
|
|
64
|
-
const context = { projectRoot, files, allFiles, program, moduleGraph, config, packageJson };
|
|
120
|
+
const context = { projectRoot, files, allFiles, textFiles, program, moduleGraph, config, packageJson };
|
|
121
|
+
nextPhase('Measuring complexity');
|
|
65
122
|
const functionMetrics = (0, complexity_1.collectFunctionMetrics)(context);
|
|
66
|
-
const allFindings = [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
...(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...(0, redundancy_1.runRedundancyRules)(context),
|
|
73
|
-
...(0, typescript_2.runTypeScriptRules)(context),
|
|
74
|
-
...(0, javascript_1.runJavaScriptRules)(context),
|
|
75
|
-
...(0, security_1.runSecurityRules)(context),
|
|
76
|
-
...(0, architecture_1.runArchitectureRules)(context),
|
|
77
|
-
...(0, hygiene_1.runHygieneRules)(context)
|
|
78
|
-
];
|
|
123
|
+
const allFindings = [...(0, complexity_1.runComplexityRules)(context, functionMetrics)];
|
|
124
|
+
for (const [label, run] of RULE_PHASES) {
|
|
125
|
+
nextPhase(label);
|
|
126
|
+
allFindings.push(...run(context));
|
|
127
|
+
}
|
|
128
|
+
nextPhase('Scoring');
|
|
79
129
|
const findings = allFindings.filter((finding) => {
|
|
80
130
|
const sourceFile = context.program.getSourceFile(node_path_1.default.resolve(projectRoot, finding.file));
|
|
81
131
|
return !sourceFile || !(0, suppressions_1.isSuppressed)(sourceFile, finding.line, finding.ruleId);
|
|
@@ -85,11 +135,10 @@ function scanRepository(projectRoot) {
|
|
|
85
135
|
let commentLOC = 0;
|
|
86
136
|
const largestFiles = [];
|
|
87
137
|
for (const file of files) {
|
|
88
|
-
const text = (0, discovery_1.readFileSafe)(file);
|
|
89
|
-
const relativePath =
|
|
90
|
-
const isTest = (0, discovery_1.isTestFile)(relativePath);
|
|
138
|
+
const text = program.getSourceFile(file)?.text ?? (0, discovery_1.readFileSafe)(file);
|
|
139
|
+
const relativePath = (0, discovery_1.toRelative)(projectRoot, file);
|
|
91
140
|
const loc = (0, discovery_1.countLines)(text);
|
|
92
|
-
if (
|
|
141
|
+
if ((0, discovery_1.isTestFile)(relativePath)) {
|
|
93
142
|
testLOC += loc;
|
|
94
143
|
}
|
|
95
144
|
else {
|
|
@@ -100,8 +149,8 @@ function scanRepository(projectRoot) {
|
|
|
100
149
|
largestFiles.push({ file: relativePath, loc });
|
|
101
150
|
}
|
|
102
151
|
}
|
|
103
|
-
const
|
|
104
|
-
const
|
|
152
|
+
const testFiles = files.filter((file) => (0, discovery_1.isTestFile)((0, discovery_1.toRelative)(projectRoot, file))).length;
|
|
153
|
+
const sourceFiles = files.length - testFiles;
|
|
105
154
|
const dependencyCount = [
|
|
106
155
|
...Object.keys(packageJson?.dependencies ?? {}),
|
|
107
156
|
...Object.keys(packageJson?.devDependencies ?? {})
|
|
@@ -137,14 +186,28 @@ function scanRepository(projectRoot) {
|
|
|
137
186
|
deadCodePercentage: functionCount + classCount > 0 ? Math.min(100, (deadCodeFindingCount / (functionCount + classCount)) * 100) : 0,
|
|
138
187
|
testToSourceRatio: sourceLOC > 0 ? testLOC / sourceLOC : 0
|
|
139
188
|
};
|
|
140
|
-
const
|
|
189
|
+
const architectureConfigured = Object.keys(config.architecture?.layers ?? {}).length > 0;
|
|
190
|
+
const { overallScore, categoryScores, securityScore, securityCapped } = (0, scoring_1.computeScores)(findings, sourceLOC + testLOC, { architectureConfigured });
|
|
191
|
+
const coverage = {
|
|
192
|
+
discoveryMethod: discovery.method,
|
|
193
|
+
discoveredFiles: allFiles.length,
|
|
194
|
+
analyzedFiles: files.length,
|
|
195
|
+
textFilesScanned: textFiles.length,
|
|
196
|
+
generatedFilesSkipped: generatedFiles.length,
|
|
197
|
+
tooLargeFilesSkipped: discovery.skipped.tooLarge,
|
|
198
|
+
binaryFilesSkipped: discovery.skipped.binary,
|
|
199
|
+
languages: countLanguages(allFiles),
|
|
200
|
+
durationMs: Date.now() - startedAt
|
|
201
|
+
};
|
|
141
202
|
return {
|
|
142
203
|
repository: projectRoot,
|
|
143
204
|
score: overallScore,
|
|
144
205
|
categoryScores,
|
|
145
206
|
securityScore,
|
|
207
|
+
securityCapped,
|
|
146
208
|
findings,
|
|
147
209
|
metrics,
|
|
148
|
-
generatedFiles
|
|
210
|
+
generatedFiles,
|
|
211
|
+
coverage
|
|
149
212
|
};
|
|
150
213
|
}
|
package/dist/scoring.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CATEGORY_WEIGHTS = void 0;
|
|
3
|
+
exports.SECURITY_CAP = exports.CATEGORY_WEIGHTS = void 0;
|
|
4
4
|
exports.computeScores = computeScores;
|
|
5
5
|
exports.summarizeTopDeductions = summarizeTopDeductions;
|
|
6
|
+
exports.summarizeHotspots = summarizeHotspots;
|
|
7
|
+
const discovery_1 = require("./discovery");
|
|
6
8
|
exports.CATEGORY_WEIGHTS = {
|
|
7
|
-
deadCode: 0.
|
|
9
|
+
deadCode: 0.15,
|
|
8
10
|
dependencies: 0.1,
|
|
9
11
|
complexity: 0.15,
|
|
10
12
|
duplication: 0.1,
|
|
11
|
-
redundantLogic: 0.
|
|
13
|
+
redundantLogic: 0.05,
|
|
12
14
|
errorHandling: 0.1,
|
|
13
15
|
typescript: 0.1,
|
|
14
|
-
architecture: 0.
|
|
15
|
-
hygiene: 0.05
|
|
16
|
+
architecture: 0.05,
|
|
17
|
+
hygiene: 0.05,
|
|
18
|
+
security: 0.15
|
|
16
19
|
};
|
|
17
20
|
const SEVERITY_WEIGHT = {
|
|
18
21
|
CRITICAL: 3,
|
|
@@ -28,27 +31,48 @@ const CONFIDENCE_WEIGHT = {
|
|
|
28
31
|
LOW: 0.3
|
|
29
32
|
};
|
|
30
33
|
const LOC_CHUNK = 500;
|
|
34
|
+
/** Findings in tests still matter, but less than the same problem in shipped code. */
|
|
35
|
+
const TEST_FILE_WEIGHT = 0.5;
|
|
36
|
+
/** A credible leaked secret or injection sink caps the overall score, however clean the rest is. */
|
|
37
|
+
exports.SECURITY_CAP = 6.9;
|
|
31
38
|
function findingPenalty(finding) {
|
|
32
|
-
|
|
39
|
+
const base = SEVERITY_WEIGHT[finding.severity] * CONFIDENCE_WEIGHT[finding.confidence];
|
|
40
|
+
return (0, discovery_1.isTestFile)(finding.file) ? base * TEST_FILE_WEIGHT : base;
|
|
33
41
|
}
|
|
34
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Quality categories are scored by density (penalty per 500 LOC), so a large codebase is not
|
|
44
|
+
* punished for its size. Security tolerance grows only with the square root of size and is capped
|
|
45
|
+
* at 3x: one leaked key is just as serious in a 50k-line repository as in a 500-line one.
|
|
46
|
+
*/
|
|
47
|
+
function normalizedCategoryScore(findings, loc, category) {
|
|
35
48
|
const penalty = findings.reduce((sum, finding) => sum + findingPenalty(finding), 0);
|
|
36
|
-
const
|
|
49
|
+
const chunks = Math.max(loc / LOC_CHUNK, 1);
|
|
50
|
+
const normalizer = category === 'security' ? Math.min(Math.sqrt(chunks), 3) : chunks;
|
|
37
51
|
return Math.max(0, Math.min(10, 10 - penalty / normalizer));
|
|
38
52
|
}
|
|
39
|
-
function
|
|
53
|
+
function hasCredibleSecurityIssue(findings) {
|
|
54
|
+
return findings.some((finding) => finding.category === 'security' &&
|
|
55
|
+
(finding.severity === 'HIGH' || finding.severity === 'CRITICAL') &&
|
|
56
|
+
(finding.confidence === 'HIGH' || finding.confidence === 'CERTAIN') &&
|
|
57
|
+
!(0, discovery_1.isTestFile)(finding.file));
|
|
58
|
+
}
|
|
59
|
+
function computeScores(findings, loc, options = {}) {
|
|
60
|
+
const architectureConfigured = options.architectureConfigured ?? true;
|
|
61
|
+
const categories = Object.keys(exports.CATEGORY_WEIGHTS).filter((category) => category !== 'architecture' || architectureConfigured);
|
|
40
62
|
const categoryScores = {};
|
|
41
|
-
for (const category of
|
|
63
|
+
for (const category of categories) {
|
|
42
64
|
const categoryFindings = findings.filter((finding) => finding.category === category);
|
|
43
|
-
categoryScores[category] = Number(normalizedCategoryScore(categoryFindings,
|
|
65
|
+
categoryScores[category] = Number(normalizedCategoryScore(categoryFindings, loc, category).toFixed(1));
|
|
44
66
|
}
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
67
|
+
const totalWeight = categories.reduce((sum, category) => sum + exports.CATEGORY_WEIGHTS[category], 0);
|
|
68
|
+
const weighted = categories.reduce((sum, category) => sum + exports.CATEGORY_WEIGHTS[category] * categoryScores[category], 0) / totalWeight;
|
|
69
|
+
const securityCapped = hasCredibleSecurityIssue(findings) && weighted > exports.SECURITY_CAP;
|
|
70
|
+
const overallScore = securityCapped ? exports.SECURITY_CAP : weighted;
|
|
48
71
|
return {
|
|
49
72
|
overallScore: Number(overallScore.toFixed(1)),
|
|
50
73
|
categoryScores,
|
|
51
|
-
securityScore
|
|
74
|
+
securityScore: categoryScores.security,
|
|
75
|
+
securityCapped
|
|
52
76
|
};
|
|
53
77
|
}
|
|
54
78
|
function summarizeTopDeductions(findings, limit = 8) {
|
|
@@ -66,3 +90,17 @@ function summarizeTopDeductions(findings, limit = 8) {
|
|
|
66
90
|
}
|
|
67
91
|
return [...byRule.values()].sort((a, b) => b.totalPenalty - a.totalPenalty).slice(0, limit);
|
|
68
92
|
}
|
|
93
|
+
/** Files ranked by total penalty: where fixing things moves the score the most. */
|
|
94
|
+
function summarizeHotspots(findings, limit = 5) {
|
|
95
|
+
const rank = { INFO: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
|
96
|
+
const byFile = new Map();
|
|
97
|
+
for (const finding of findings) {
|
|
98
|
+
const entry = byFile.get(finding.file) ?? { file: finding.file, count: 0, penalty: 0, worst: 'INFO' };
|
|
99
|
+
entry.count += 1;
|
|
100
|
+
entry.penalty += findingPenalty(finding);
|
|
101
|
+
if (rank[finding.severity] > rank[entry.worst])
|
|
102
|
+
entry.worst = finding.severity;
|
|
103
|
+
byFile.set(finding.file, entry);
|
|
104
|
+
}
|
|
105
|
+
return [...byFile.values()].sort((a, b) => b.penalty - a.penalty).slice(0, limit);
|
|
106
|
+
}
|
package/dist/suppressions.js
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.collectSuppressions = collectSuppressions;
|
|
4
|
+
exports.collectFileSuppression = collectFileSuppression;
|
|
4
5
|
exports.isSuppressed = isSuppressed;
|
|
5
6
|
const comments_1 = require("./ast/comments");
|
|
7
|
+
const RULE_ID_LIST = '([A-Z]{2,}\\d{3}(?:\\s*,\\s*[A-Z]{2,}\\d{3})*)';
|
|
8
|
+
const NEXT_LINE_PATTERN = new RegExp(`devkit-disable-next-line(?:\\s+${RULE_ID_LIST})?`, 'i');
|
|
9
|
+
const FILE_PATTERN = new RegExp(`devkit-disable-file(?:\\s+${RULE_ID_LIST})?`, 'i');
|
|
10
|
+
function parseRuleIdList(match) {
|
|
11
|
+
return match[1] ? new Set(match[1].split(',').map((id) => id.trim().toUpperCase())) : null;
|
|
12
|
+
}
|
|
6
13
|
function collectSuppressions(sourceFile) {
|
|
7
14
|
const directives = [];
|
|
8
15
|
for (const range of (0, comments_1.collectCommentRanges)(sourceFile)) {
|
|
9
16
|
const text = sourceFile.text.slice(range.pos, range.end);
|
|
10
|
-
const match =
|
|
17
|
+
const match = NEXT_LINE_PATTERN.exec(text);
|
|
11
18
|
if (!match)
|
|
12
19
|
continue;
|
|
13
20
|
const line = sourceFile.getLineAndCharacterOfPosition(range.end).line + 2;
|
|
14
|
-
|
|
15
|
-
directives.push({ line, ruleIds });
|
|
21
|
+
directives.push({ line, ruleIds: parseRuleIdList(match) });
|
|
16
22
|
}
|
|
17
23
|
return directives;
|
|
18
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* `// devkit-disable-file` (optionally followed by rule IDs) anywhere in a file silences those
|
|
27
|
+
* rules for the whole file — for a vendored copy, a generated-looking file that content
|
|
28
|
+
* detection missed, or a file a team has deliberately decided not to clean up yet.
|
|
29
|
+
*/
|
|
30
|
+
function collectFileSuppression(sourceFile) {
|
|
31
|
+
for (const range of (0, comments_1.collectCommentRanges)(sourceFile)) {
|
|
32
|
+
const match = FILE_PATTERN.exec(sourceFile.text.slice(range.pos, range.end));
|
|
33
|
+
if (match)
|
|
34
|
+
return { ruleIds: parseRuleIdList(match) };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
19
38
|
function isSuppressed(sourceFile, line, ruleId) {
|
|
20
|
-
|
|
39
|
+
const upperRuleId = ruleId.toUpperCase();
|
|
40
|
+
const fileSuppression = collectFileSuppression(sourceFile);
|
|
41
|
+
if (fileSuppression && (!fileSuppression.ruleIds || fileSuppression.ruleIds.has(upperRuleId))) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return collectSuppressions(sourceFile).some((directive) => directive.line === line && (!directive.ruleIds || directive.ruleIds.has(upperRuleId)));
|
|
21
45
|
}
|