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.
@@ -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
- tokens.push({ text: scanner.getTokenText(), line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1, kind });
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.startLineA - b.startLineA);
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
- if (last && range.startLineA <= last.endLineA + 1 && range.startLineB <= last.endLineB + 1) {
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
- if (a.file === b.file && a.startIndex === b.startIndex)
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 spanLines = range.endLineA - range.startLineA + 1;
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(spanLines * WINDOW_STEP, minTokens),
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
- if ((0, config_1.isRuleEnabled)(context.config, 'ERR001') && statements.length === 0) {
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,
@@ -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', description: 'Console output remains in source code.', evidence: node.getText().slice(0, 160),
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
  }
@@ -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
- for (const file of context.files) {
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
- const fallbackMatch = !providerPattern && !isTestFixture ? lines[i].match(SECRET_PATTERN) : null;
77
- if (providerPattern || (fallbackMatch && (0, secretPatterns_1.looksHighEntropy)(fallbackMatch[2]))) {
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.allFiles) {
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 scanRepository(projectRoot) {
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 allFiles = (0, discovery_1.walkDirectory)(projectRoot);
59
- const { files, generatedFiles } = (0, discovery_1.filterScannedFiles)(allFiles, projectRoot, config, (0, ignore_1.loadIgnorePatterns)(projectRoot));
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
- const packageJson = node_fs_1.default.existsSync(packageJsonPath) ? JSON.parse((0, discovery_1.readFileSafe)(packageJsonPath)) : null;
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
- ...(0, deadCode_1.runDeadCodeRules)(context),
68
- ...(0, dependencies_1.runDependencyRules)(context),
69
- ...(0, complexity_1.runComplexityRules)(context, functionMetrics),
70
- ...(0, duplication_1.runDuplicationRules)(context),
71
- ...(0, errorHandling_1.runErrorHandlingRules)(context),
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 = node_path_1.default.relative(projectRoot, file).replace(/\\/g, '/');
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 (isTest) {
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 sourceFiles = files.filter((file) => !(0, discovery_1.isTestFile)(file)).length;
104
- const testFiles = files.filter((file) => (0, discovery_1.isTestFile)(file)).length;
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 { overallScore, categoryScores, securityScore } = (0, scoring_1.computeScores)(findings, sourceLOC);
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.2,
9
+ deadCode: 0.15,
8
10
  dependencies: 0.1,
9
11
  complexity: 0.15,
10
12
  duplication: 0.1,
11
- redundantLogic: 0.1,
13
+ redundantLogic: 0.05,
12
14
  errorHandling: 0.1,
13
15
  typescript: 0.1,
14
- architecture: 0.1,
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
- return SEVERITY_WEIGHT[finding.severity] * CONFIDENCE_WEIGHT[finding.confidence];
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
- function normalizedCategoryScore(findings, sourceLOC) {
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 normalizer = Math.max(sourceLOC / LOC_CHUNK, 1);
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 computeScores(findings, sourceLOC) {
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 Object.keys(exports.CATEGORY_WEIGHTS)) {
63
+ for (const category of categories) {
42
64
  const categoryFindings = findings.filter((finding) => finding.category === category);
43
- categoryScores[category] = Number(normalizedCategoryScore(categoryFindings, sourceLOC).toFixed(1));
65
+ categoryScores[category] = Number(normalizedCategoryScore(categoryFindings, loc, category).toFixed(1));
44
66
  }
45
- const overallScore = Object.entries(exports.CATEGORY_WEIGHTS).reduce((sum, [category, weight]) => sum + weight * categoryScores[category], 0);
46
- const securityFindings = findings.filter((finding) => finding.category === 'security');
47
- const securityScore = Number(normalizedCategoryScore(securityFindings, sourceLOC).toFixed(1));
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
+ }
@@ -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 = /devkit-disable-next-line(?:\s+([A-Z]{2,}\d{3}(?:\s*,\s*[A-Z]{2,}\d{3})*))?/i.exec(text);
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
- const ruleIds = match[1] ? new Set(match[1].split(',').map((id) => id.trim().toUpperCase())) : null;
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
- return collectSuppressions(sourceFile).some((directive) => directive.line === line && (!directive.ruleIds || directive.ruleIds.has(ruleId.toUpperCase())));
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
  }