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.
@@ -0,0 +1,252 @@
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.runDeadCodeRules = runDeadCodeRules;
7
+ const typescript_1 = __importDefault(require("typescript"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const finding_1 = require("../finding");
10
+ const config_1 = require("../config");
11
+ const discovery_1 = require("../discovery");
12
+ const walk_1 = require("../ast/walk");
13
+ const UNUSED_DIAGNOSTIC_CODES = new Set([6133, 6192, 6196, 6198]);
14
+ function hasDynamicRequireHint(context, relativePath) {
15
+ for (const hint of context.moduleGraph.dynamicRequireHints) {
16
+ if (hint === '.' || relativePath === hint || relativePath.startsWith(`${hint}/`))
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ function classifyUnusedDiagnostic(node, code) {
22
+ if (code === 6192 || typescript_1.default.isImportDeclaration(node) || typescript_1.default.isImportEqualsDeclaration(node)) {
23
+ return { ruleId: 'DEAD002', title: 'Unused import', fixAvailable: true };
24
+ }
25
+ const importAncestor = typescript_1.default.findAncestor(node, (ancestor) => typescript_1.default.isImportSpecifier(ancestor) || typescript_1.default.isNamespaceImport(ancestor) || typescript_1.default.isImportClause(ancestor));
26
+ if (importAncestor) {
27
+ return { ruleId: 'DEAD002', title: 'Unused import', fixAvailable: true };
28
+ }
29
+ if (code === 6196) {
30
+ return { ruleId: 'DEAD007', title: 'Unused type', fixAvailable: false };
31
+ }
32
+ const parameterAncestor = typescript_1.default.findAncestor(node, typescript_1.default.isParameter);
33
+ if (parameterAncestor) {
34
+ return { ruleId: 'DEAD006', title: 'Unused parameter', fixAvailable: false };
35
+ }
36
+ const classAncestor = typescript_1.default.findAncestor(node, typescript_1.default.isClassDeclaration);
37
+ if (classAncestor && classAncestor.name === node) {
38
+ return { ruleId: 'DEAD005', title: 'Unused class', fixAvailable: false };
39
+ }
40
+ const functionAncestor = typescript_1.default.findAncestor(node, typescript_1.default.isFunctionDeclaration);
41
+ if (functionAncestor && functionAncestor.name === node) {
42
+ return { ruleId: 'DEAD004', title: 'Unused function', fixAvailable: false };
43
+ }
44
+ return { ruleId: 'DEAD003', title: 'Unused variable', fixAvailable: true };
45
+ }
46
+ function detectUnusedViaDiagnostics(context) {
47
+ const findings = [];
48
+ for (const file of context.files) {
49
+ const sourceFile = context.program.getSourceFile(file);
50
+ if (!sourceFile) {
51
+ continue;
52
+ }
53
+ let diagnostics = [];
54
+ try {
55
+ diagnostics = context.program.getSemanticDiagnostics(sourceFile);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
61
+ for (const diagnostic of diagnostics) {
62
+ if (diagnostic.start === undefined || !UNUSED_DIAGNOSTIC_CODES.has(diagnostic.code)) {
63
+ continue;
64
+ }
65
+ const node = (0, walk_1.findNodeAtPosition)(sourceFile, diagnostic.start);
66
+ const { ruleId, title, fixAvailable } = classifyUnusedDiagnostic(node, diagnostic.code);
67
+ if (!(0, config_1.isRuleEnabled)(context.config, ruleId)) {
68
+ continue;
69
+ }
70
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, diagnostic.start);
71
+ const message = typescript_1.default.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
72
+ findings.push((0, finding_1.buildFinding)({
73
+ ruleId,
74
+ category: 'deadCode',
75
+ severity: ruleId === 'DEAD006' ? 'LOW' : 'MEDIUM',
76
+ confidence: 'CERTAIN',
77
+ file: relativePath,
78
+ line,
79
+ column,
80
+ message: title,
81
+ description: message,
82
+ evidence: sourceFile.text.slice(node.getStart(), Math.min(node.getEnd(), node.getStart() + 160)),
83
+ suggestion: fixAvailable
84
+ ? 'Remove the unused declaration.'
85
+ : 'Remove it if truly unused, or export it if it is part of the public API.',
86
+ fixAvailable
87
+ }));
88
+ }
89
+ }
90
+ return findings;
91
+ }
92
+ function detectUnusedExports(context) {
93
+ if (!(0, config_1.isRuleEnabled)(context.config, 'DEAD009')) {
94
+ return [];
95
+ }
96
+ const findings = [];
97
+ const { moduleGraph } = context;
98
+ const usedExportsByFile = new Map();
99
+ const fullyUsedFiles = new Set();
100
+ for (const [, imports] of moduleGraph.importsByFile) {
101
+ for (const importInfo of imports) {
102
+ if (!importInfo.resolved) {
103
+ continue;
104
+ }
105
+ if (importInfo.hasNamespaceImport) {
106
+ fullyUsedFiles.add(importInfo.resolved);
107
+ continue;
108
+ }
109
+ if (!usedExportsByFile.has(importInfo.resolved)) {
110
+ usedExportsByFile.set(importInfo.resolved, new Set());
111
+ }
112
+ const used = usedExportsByFile.get(importInfo.resolved);
113
+ if (importInfo.hasDefaultImport) {
114
+ used.add('default');
115
+ }
116
+ for (const name of importInfo.namedImports) {
117
+ used.add(name);
118
+ }
119
+ }
120
+ }
121
+ for (const [, targets] of moduleGraph.reExportAllTargets) {
122
+ for (const target of targets) {
123
+ fullyUsedFiles.add(target);
124
+ }
125
+ }
126
+ for (const [file, exports] of moduleGraph.exportsByFile) {
127
+ if (exports.length === 0)
128
+ continue;
129
+ if (moduleGraph.entryPoints.has(file))
130
+ continue;
131
+ if ((0, discovery_1.isTestFile)(file))
132
+ continue;
133
+ if (fullyUsedFiles.has(file))
134
+ continue;
135
+ const used = usedExportsByFile.get(file) ?? new Set();
136
+ const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
137
+ const dynamicHint = hasDynamicRequireHint(context, relativePath);
138
+ for (const exportInfo of exports) {
139
+ if (used.has(exportInfo.name)) {
140
+ continue;
141
+ }
142
+ findings.push((0, finding_1.buildFinding)({
143
+ ruleId: 'DEAD009',
144
+ category: 'deadCode',
145
+ severity: 'MEDIUM',
146
+ confidence: dynamicHint ? 'LOW' : 'MEDIUM',
147
+ file: relativePath,
148
+ line: exportInfo.line,
149
+ column: 1,
150
+ message: 'Unused export',
151
+ description: `Export "${exportInfo.name}" is not imported by any other file in the project.${dynamicHint ? ' A nearby dynamic require/import could not be resolved, so verify this manually.' : ''}`,
152
+ evidence: exportInfo.name,
153
+ suggestion: 'Remove the export or confirm it is part of a public API consumed outside this repository.',
154
+ fixAvailable: false
155
+ }));
156
+ }
157
+ }
158
+ return findings;
159
+ }
160
+ function detectUnusedFiles(context) {
161
+ if (!(0, config_1.isRuleEnabled)(context.config, 'DEAD010')) {
162
+ return [];
163
+ }
164
+ const findings = [];
165
+ const { moduleGraph } = context;
166
+ for (const file of context.files) {
167
+ if ((0, discovery_1.isTestFile)(file))
168
+ continue;
169
+ if (moduleGraph.entryPoints.has(file))
170
+ continue;
171
+ const incoming = moduleGraph.reverseEdges.get(file);
172
+ if (incoming && incoming.size > 0)
173
+ continue;
174
+ const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
175
+ const dynamicHint = hasDynamicRequireHint(context, relativePath);
176
+ findings.push((0, finding_1.buildFinding)({
177
+ ruleId: 'DEAD010',
178
+ category: 'deadCode',
179
+ severity: 'MEDIUM',
180
+ confidence: dynamicHint ? 'LOW' : 'MEDIUM',
181
+ file: relativePath,
182
+ line: 1,
183
+ column: 1,
184
+ message: 'Unused file',
185
+ description: `This file is never imported by any other file and is not a configured entry point.${dynamicHint ? ' A nearby dynamic require/import could not be resolved, so verify this manually.' : ''}`,
186
+ evidence: relativePath,
187
+ suggestion: 'Delete the file or wire it up as a reachable entry point.',
188
+ fixAvailable: false
189
+ }));
190
+ }
191
+ return findings;
192
+ }
193
+ function detectUnreachableStatements(context) {
194
+ if (!(0, config_1.isRuleEnabled)(context.config, 'DEAD011')) {
195
+ return [];
196
+ }
197
+ const findings = [];
198
+ for (const file of context.files) {
199
+ const sourceFile = context.program.getSourceFile(file);
200
+ if (!sourceFile)
201
+ continue;
202
+ const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
203
+ const checkStatementList = (statements) => {
204
+ let terminated = false;
205
+ for (const statement of statements) {
206
+ if (terminated) {
207
+ if (typescript_1.default.isFunctionDeclaration(statement)) {
208
+ continue;
209
+ }
210
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, statement.getStart());
211
+ findings.push((0, finding_1.buildFinding)({
212
+ ruleId: 'DEAD011',
213
+ category: 'deadCode',
214
+ severity: 'MEDIUM',
215
+ confidence: 'CERTAIN',
216
+ file: relativePath,
217
+ line,
218
+ column,
219
+ message: 'Unreachable statement',
220
+ description: 'Code following a return, throw, break, or continue statement can never execute.',
221
+ evidence: statement.getText().slice(0, 160),
222
+ suggestion: 'Remove the unreachable code.',
223
+ fixAvailable: true
224
+ }));
225
+ break;
226
+ }
227
+ if (typescript_1.default.isReturnStatement(statement) ||
228
+ typescript_1.default.isThrowStatement(statement) ||
229
+ typescript_1.default.isBreakStatement(statement) ||
230
+ typescript_1.default.isContinueStatement(statement)) {
231
+ terminated = true;
232
+ }
233
+ }
234
+ };
235
+ const visit = (node) => {
236
+ if ((0, walk_1.isStatementContainer)(node)) {
237
+ checkStatementList(node.statements);
238
+ }
239
+ typescript_1.default.forEachChild(node, visit);
240
+ };
241
+ visit(sourceFile);
242
+ }
243
+ return findings;
244
+ }
245
+ function runDeadCodeRules(context) {
246
+ return [
247
+ ...detectUnusedViaDiagnostics(context),
248
+ ...detectUnusedExports(context),
249
+ ...detectUnusedFiles(context),
250
+ ...detectUnreachableStatements(context)
251
+ ];
252
+ }
@@ -0,0 +1,137 @@
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.runDependencyRules = runDependencyRules;
7
+ const node_module_1 = require("node:module");
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const finding_1 = require("../finding");
10
+ const config_1 = require("../config");
11
+ const discovery_1 = require("../discovery");
12
+ const moduleGraph_1 = require("../moduleGraph");
13
+ const BUILTIN_MODULES = new Set([...node_module_1.builtinModules, ...node_module_1.builtinModules.map((name) => `node:${name}`)]);
14
+ function packageNameFromSpecifier(specifier) {
15
+ if (specifier.startsWith('@')) {
16
+ const parts = specifier.split('/');
17
+ return parts.slice(0, 2).join('/');
18
+ }
19
+ return specifier.split('/')[0];
20
+ }
21
+ function findDependencyLine(packageJsonText, dependencyName) {
22
+ const escaped = dependencyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ const pattern = new RegExp(`"${escaped}"\\s*:`);
24
+ const lines = packageJsonText.split(/\r\n|\r|\n/);
25
+ for (let i = 0; i < lines.length; i += 1) {
26
+ if (pattern.test(lines[i])) {
27
+ return i + 1;
28
+ }
29
+ }
30
+ return 1;
31
+ }
32
+ function getDeclaredDependencyNames(packageJson) {
33
+ if (!packageJson)
34
+ return new Set();
35
+ const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
36
+ const names = new Set();
37
+ for (const field of fields) {
38
+ const section = packageJson[field];
39
+ if (section) {
40
+ for (const name of Object.keys(section)) {
41
+ names.add(name);
42
+ }
43
+ }
44
+ }
45
+ return names;
46
+ }
47
+ function runDependencyRules(context) {
48
+ const findings = [];
49
+ const { moduleGraph, packageJson, projectRoot } = context;
50
+ const packageJsonText = (0, discovery_1.readFileSafe)(node_path_1.default.join(projectRoot, 'package.json'));
51
+ const usedPackageNames = new Set();
52
+ for (const [, imports] of moduleGraph.importsByFile) {
53
+ for (const importInfo of imports) {
54
+ if (!importInfo.isRelative && !importInfo.resolved) {
55
+ usedPackageNames.add(packageNameFromSpecifier(importInfo.specifier));
56
+ }
57
+ }
58
+ }
59
+ const scripts = packageJson?.scripts ?? {};
60
+ const scriptText = Object.values(scripts).join(' ');
61
+ const declaredDependencies = getDeclaredDependencyNames(packageJson);
62
+ if ((0, config_1.isRuleEnabled)(context.config, 'DEP001')) {
63
+ for (const dependencyName of declaredDependencies) {
64
+ if (usedPackageNames.has(dependencyName))
65
+ continue;
66
+ // @types/* packages provide ambient global declarations (process, Buffer, JSX, ...)
67
+ // that are never referenced through an import specifier, so they are exempt by design.
68
+ if (dependencyName.startsWith('@types/'))
69
+ continue;
70
+ const wordBoundary = new RegExp(`(^|[^A-Za-z0-9_@/.-])${dependencyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_@/.-]|$)`);
71
+ if (wordBoundary.test(scriptText))
72
+ continue;
73
+ findings.push((0, finding_1.buildFinding)({
74
+ ruleId: 'DEP001',
75
+ category: 'dependencies',
76
+ severity: 'MEDIUM',
77
+ confidence: 'CERTAIN',
78
+ file: 'package.json',
79
+ line: findDependencyLine(packageJsonText, dependencyName),
80
+ column: 1,
81
+ message: `Unused dependency: ${dependencyName}`,
82
+ description: `"${dependencyName}" is declared but not referenced by source files, require/import calls, or package.json scripts.`,
83
+ evidence: dependencyName,
84
+ suggestion: 'Remove the unused dependency or confirm it is required by a toolchain entry point.',
85
+ fixAvailable: true
86
+ }));
87
+ }
88
+ }
89
+ if ((0, config_1.isRuleEnabled)(context.config, 'DEP002')) {
90
+ for (const usedName of usedPackageNames) {
91
+ if (BUILTIN_MODULES.has(usedName))
92
+ continue;
93
+ if (declaredDependencies.has(usedName))
94
+ continue;
95
+ findings.push((0, finding_1.buildFinding)({
96
+ ruleId: 'DEP002',
97
+ category: 'dependencies',
98
+ severity: 'HIGH',
99
+ confidence: 'HIGH',
100
+ file: 'package.json',
101
+ line: 1,
102
+ column: 1,
103
+ message: `Unlisted dependency: ${usedName}`,
104
+ description: `"${usedName}" is imported from source but is not declared in package.json.`,
105
+ evidence: usedName,
106
+ suggestion: 'Add the package to package.json dependencies, or remove the import if it was unintentional.',
107
+ fixAvailable: false
108
+ }));
109
+ }
110
+ }
111
+ if ((0, config_1.isRuleEnabled)(context.config, 'DEP003')) {
112
+ const cycles = (0, moduleGraph_1.findImportCycles)(moduleGraph);
113
+ const reported = new Set();
114
+ for (const cycle of cycles) {
115
+ const relativeCycle = cycle.map((file) => node_path_1.default.relative(projectRoot, file).replace(/\\/g, '/'));
116
+ const key = [...relativeCycle].sort().join('|');
117
+ if (reported.has(key))
118
+ continue;
119
+ reported.add(key);
120
+ findings.push((0, finding_1.buildFinding)({
121
+ ruleId: 'DEP003',
122
+ category: 'dependencies',
123
+ severity: 'MEDIUM',
124
+ confidence: 'HIGH',
125
+ file: relativeCycle[0],
126
+ line: 1,
127
+ column: 1,
128
+ message: 'Circular import detected',
129
+ description: 'A cycle exists in the internal module import graph.',
130
+ evidence: relativeCycle.join(' -> '),
131
+ suggestion: 'Break the cycle by extracting shared code into a separate module.',
132
+ fixAvailable: false
133
+ }));
134
+ }
135
+ }
136
+ return findings;
137
+ }
@@ -0,0 +1,138 @@
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.normalizeWindow = normalizeWindow;
7
+ exports.runDuplicationRules = runDuplicationRules;
8
+ const node_crypto_1 = __importDefault(require("node:crypto"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const typescript_1 = __importDefault(require("typescript"));
11
+ const finding_1 = require("../finding");
12
+ const config_1 = require("../config");
13
+ const DEFAULT_MIN_TOKENS = 40;
14
+ const WINDOW_STEP = 5;
15
+ function tokenize(sourceFile, languageVariant) {
16
+ const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.ES2022, true, languageVariant, sourceFile.text);
17
+ const tokens = [];
18
+ let kind = scanner.scan();
19
+ while (kind !== typescript_1.default.SyntaxKind.EndOfFileToken) {
20
+ const pos = scanner.getTokenStart();
21
+ tokens.push({ text: scanner.getTokenText(), line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1, kind });
22
+ kind = scanner.scan();
23
+ }
24
+ return tokens;
25
+ }
26
+ function normalizeWindow(window) {
27
+ const identifiers = new Map();
28
+ let nextIdentifier = 0;
29
+ return window.map((token) => {
30
+ if (token.kind !== typescript_1.default.SyntaxKind.Identifier && token.kind !== typescript_1.default.SyntaxKind.PrivateIdentifier)
31
+ return token.text;
32
+ if (!identifiers.has(token.text))
33
+ identifiers.set(token.text, `IDENT_${nextIdentifier++}`);
34
+ return identifiers.get(token.text);
35
+ }).join('\u0001');
36
+ }
37
+ function severityForSpan(spanTokens, minTokens) {
38
+ if (spanTokens >= minTokens * 4)
39
+ return 'HIGH';
40
+ if (spanTokens >= minTokens * 2)
41
+ return 'MEDIUM';
42
+ return 'LOW';
43
+ }
44
+ function mergeRanges(ranges) {
45
+ const sorted = [...ranges].sort((a, b) => a.startLineA - b.startLineA);
46
+ const merged = [];
47
+ for (const range of sorted) {
48
+ const last = merged[merged.length - 1];
49
+ if (last && range.startLineA <= last.endLineA + 1 && range.startLineB <= last.endLineB + 1) {
50
+ last.endLineA = Math.max(last.endLineA, range.endLineA);
51
+ last.endLineB = Math.max(last.endLineB, range.endLineB);
52
+ }
53
+ else {
54
+ merged.push({ ...range });
55
+ }
56
+ }
57
+ return merged;
58
+ }
59
+ function runDuplicationRules(context) {
60
+ if (!(0, config_1.isRuleEnabled)(context.config, 'DUP001')) {
61
+ return [];
62
+ }
63
+ const minTokens = (0, config_1.getRuleThreshold)(context.config, 'DUP001', DEFAULT_MIN_TOKENS);
64
+ const hashBuckets = new Map();
65
+ const relPath = (file) => node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
66
+ for (const file of context.files) {
67
+ const sourceFile = context.program.getSourceFile(file);
68
+ if (!sourceFile)
69
+ continue;
70
+ const variant = /\.(tsx|jsx)$/i.test(file) ? typescript_1.default.LanguageVariant.JSX : typescript_1.default.LanguageVariant.Standard;
71
+ const tokens = tokenize(sourceFile, variant);
72
+ if (tokens.length < minTokens)
73
+ continue;
74
+ const relative = relPath(file);
75
+ for (let i = 0; i + minTokens <= tokens.length; i += WINDOW_STEP) {
76
+ const window = tokens.slice(i, i + minTokens);
77
+ const joined = normalizeWindow(window);
78
+ const hash = node_crypto_1.default.createHash('sha1').update(joined).digest('hex');
79
+ const entry = {
80
+ file: relative,
81
+ startLine: window[0].line,
82
+ endLine: window[window.length - 1].line,
83
+ startIndex: i
84
+ };
85
+ if (!hashBuckets.has(hash))
86
+ hashBuckets.set(hash, []);
87
+ hashBuckets.get(hash).push(entry);
88
+ }
89
+ }
90
+ const pairRanges = new Map();
91
+ for (const entries of hashBuckets.values()) {
92
+ if (entries.length < 2)
93
+ continue;
94
+ for (let i = 0; i < entries.length; i += 1) {
95
+ for (let j = i + 1; j < entries.length; j += 1) {
96
+ const a = entries[i];
97
+ const b = entries[j];
98
+ if (a.file === b.file && a.startIndex === b.startIndex)
99
+ continue;
100
+ const key = a.file <= b.file ? `${a.file}::${b.file}` : `${b.file}::${a.file}`;
101
+ const [first, second] = a.file <= b.file ? [a, b] : [b, a];
102
+ if (!pairRanges.has(key))
103
+ pairRanges.set(key, []);
104
+ pairRanges.get(key).push({
105
+ startLineA: first.startLine,
106
+ endLineA: first.endLine,
107
+ startLineB: second.startLine,
108
+ endLineB: second.endLine
109
+ });
110
+ }
111
+ }
112
+ }
113
+ const findings = [];
114
+ for (const [key, ranges] of pairRanges) {
115
+ const [fileA, fileB] = key.split('::');
116
+ const merged = mergeRanges(ranges);
117
+ for (const range of merged) {
118
+ const spanLines = range.endLineA - range.startLineA + 1;
119
+ if (spanLines * WINDOW_STEP < minTokens)
120
+ continue;
121
+ findings.push((0, finding_1.buildFinding)({
122
+ ruleId: 'DUP001',
123
+ category: 'duplication',
124
+ severity: severityForSpan(spanLines * WINDOW_STEP, minTokens),
125
+ confidence: 'MEDIUM',
126
+ file: fileA,
127
+ line: range.startLineA,
128
+ column: 1,
129
+ message: 'Duplicate code block',
130
+ description: `Lines ${range.startLineA}-${range.endLineA} closely match ${fileB}:${range.startLineB}-${range.endLineB}.`,
131
+ evidence: `${fileB}:${range.startLineB}-${range.endLineB}`,
132
+ suggestion: 'Extract the shared logic into a reusable function or module.',
133
+ fixAvailable: false
134
+ }));
135
+ }
136
+ }
137
+ return findings;
138
+ }
@@ -0,0 +1,120 @@
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.runErrorHandlingRules = runErrorHandlingRules;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const typescript_1 = __importDefault(require("typescript"));
9
+ const finding_1 = require("../finding");
10
+ const config_1 = require("../config");
11
+ const walk_1 = require("../ast/walk");
12
+ function isPromiseLike(checker, expression) {
13
+ let type;
14
+ try {
15
+ type = checker.getTypeAtLocation(expression);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ if (type.flags & (typescript_1.default.TypeFlags.Any | typescript_1.default.TypeFlags.Unknown))
21
+ return false;
22
+ const thenProperty = checker.getPropertyOfType(type, 'then');
23
+ if (!thenProperty)
24
+ return false;
25
+ const thenType = checker.getTypeOfSymbolAtLocation(thenProperty, expression);
26
+ return checker.getSignaturesOfType(thenType, typescript_1.default.SignatureKind.Call).length > 0;
27
+ }
28
+ function outerPromiseChain(node) {
29
+ let current = node;
30
+ while (current.parent) {
31
+ if (typescript_1.default.isPropertyAccessExpression(current.parent) && current.parent.expression === current) {
32
+ current = current.parent;
33
+ continue;
34
+ }
35
+ if (typescript_1.default.isCallExpression(current.parent) && current.parent.expression === current) {
36
+ current = current.parent;
37
+ continue;
38
+ }
39
+ break;
40
+ }
41
+ return typescript_1.default.isCallExpression(current) ? current : node;
42
+ }
43
+ function propertyCallName(call) {
44
+ return typescript_1.default.isPropertyAccessExpression(call.expression) ? call.expression.name.text : null;
45
+ }
46
+ function promiseChainHasCatch(call) {
47
+ let current = call;
48
+ while (typescript_1.default.isCallExpression(current)) {
49
+ if (propertyCallName(current) === 'catch')
50
+ return true;
51
+ if (!typescript_1.default.isPropertyAccessExpression(current.expression))
52
+ return false;
53
+ current = current.expression.expression;
54
+ }
55
+ return false;
56
+ }
57
+ function runErrorHandlingRules(context) {
58
+ const findings = [];
59
+ const checker = context.program.getTypeChecker();
60
+ for (const file of context.files) {
61
+ const sourceFile = context.program.getSourceFile(file);
62
+ if (!sourceFile)
63
+ continue;
64
+ const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
65
+ const visit = (node) => {
66
+ if (typescript_1.default.isCatchClause(node)) {
67
+ const statements = node.block.statements;
68
+ if ((0, config_1.isRuleEnabled)(context.config, 'ERR001') && statements.length === 0) {
69
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
70
+ findings.push((0, finding_1.buildFinding)({
71
+ ruleId: 'ERR001', category: 'errorHandling', severity: 'MEDIUM', confidence: 'CERTAIN', file: relativePath, line, column,
72
+ message: 'Empty catch block', description: 'An exception is caught and silently discarded.', evidence: node.getText().slice(0, 160),
73
+ suggestion: 'Handle the error, log it, or document why it is intentionally ignored.', fixAvailable: false
74
+ }));
75
+ }
76
+ if ((0, config_1.isRuleEnabled)(context.config, 'ERR002') && statements.length === 1 && typescript_1.default.isThrowStatement(statements[0])) {
77
+ const throwStatement = statements[0];
78
+ const catchVarName = node.variableDeclaration && typescript_1.default.isIdentifier(node.variableDeclaration.name) ? node.variableDeclaration.name.text : undefined;
79
+ const rethrowsSameValue = !throwStatement.expression || (catchVarName && typescript_1.default.isIdentifier(throwStatement.expression) && throwStatement.expression.text === catchVarName);
80
+ if (rethrowsSameValue) {
81
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
82
+ findings.push((0, finding_1.buildFinding)({
83
+ ruleId: 'ERR002', category: 'errorHandling', severity: 'LOW', confidence: 'HIGH', file: relativePath, line, column,
84
+ message: 'Catch-and-rethrow without modification', description: 'The catch block only rethrows the original error, adding no value.',
85
+ evidence: node.getText().slice(0, 160), suggestion: 'Remove the try/catch, or add meaningful handling (logging, wrapping, recovery).', fixAvailable: false
86
+ }));
87
+ }
88
+ }
89
+ }
90
+ if ((0, config_1.isRuleEnabled)(context.config, 'ERR003') && typescript_1.default.isExpressionStatement(node) && typescript_1.default.isCallExpression(node.expression)) {
91
+ const call = node.expression;
92
+ if (propertyCallName(call) !== 'then' && !promiseChainHasCatch(call) && isPromiseLike(checker, call)) {
93
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
94
+ findings.push((0, finding_1.buildFinding)({
95
+ ruleId: 'ERR003', category: 'errorHandling', severity: 'MEDIUM', confidence: 'HIGH', file: relativePath, line, column,
96
+ message: 'Floating promise', description: 'The result of this Promise-like call is neither awaited nor handled.', evidence: node.getText().slice(0, 160),
97
+ suggestion: 'Add await, or explicitly handle rejection with .catch().', fixAvailable: false
98
+ }));
99
+ }
100
+ }
101
+ if ((0, config_1.isRuleEnabled)(context.config, 'ERR004') && typescript_1.default.isCallExpression(node) && propertyCallName(node) === 'then') {
102
+ const chain = outerPromiseChain(node);
103
+ if (chain === node && propertyCallName(chain) !== 'catch') {
104
+ const statement = typescript_1.default.findAncestor(chain, typescript_1.default.isExpressionStatement);
105
+ if (statement && statement.expression === chain) {
106
+ const { line, column } = (0, walk_1.lineAndColumn)(sourceFile, node.getStart());
107
+ findings.push((0, finding_1.buildFinding)({
108
+ ruleId: 'ERR004', category: 'errorHandling', severity: 'MEDIUM', confidence: 'MEDIUM', file: relativePath, line, column,
109
+ message: 'Promise chain has no rejection handler', description: 'This .then() chain is not followed by .catch(), so a rejected Promise may go unhandled.',
110
+ evidence: statement.getText().slice(0, 160), suggestion: 'Add a .catch() handler or use try/catch with await.', fixAvailable: false
111
+ }));
112
+ }
113
+ }
114
+ }
115
+ typescript_1.default.forEachChild(node, visit);
116
+ };
117
+ visit(sourceFile);
118
+ }
119
+ return findings;
120
+ }