@behavioral-contracts/verify-cli 1.0.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 +119 -0
- package/README.md +694 -0
- package/dist/analyze-results.js +253 -0
- package/dist/analyzer.d.ts +366 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +2592 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/analyzers/async-error-analyzer.d.ts +72 -0
- package/dist/analyzers/async-error-analyzer.d.ts.map +1 -0
- package/dist/analyzers/async-error-analyzer.js +243 -0
- package/dist/analyzers/async-error-analyzer.js.map +1 -0
- package/dist/analyzers/event-listener-analyzer.d.ts +102 -0
- package/dist/analyzers/event-listener-analyzer.d.ts.map +1 -0
- package/dist/analyzers/event-listener-analyzer.js +253 -0
- package/dist/analyzers/event-listener-analyzer.js.map +1 -0
- package/dist/analyzers/react-query-analyzer.d.ts +66 -0
- package/dist/analyzers/react-query-analyzer.d.ts.map +1 -0
- package/dist/analyzers/react-query-analyzer.js +341 -0
- package/dist/analyzers/react-query-analyzer.js.map +1 -0
- package/dist/analyzers/return-value-analyzer.d.ts +61 -0
- package/dist/analyzers/return-value-analyzer.d.ts.map +1 -0
- package/dist/analyzers/return-value-analyzer.js +225 -0
- package/dist/analyzers/return-value-analyzer.js.map +1 -0
- package/dist/code-snippet.d.ts +48 -0
- package/dist/code-snippet.d.ts.map +1 -0
- package/dist/code-snippet.js +84 -0
- package/dist/code-snippet.js.map +1 -0
- package/dist/corpus-loader.d.ts +33 -0
- package/dist/corpus-loader.d.ts.map +1 -0
- package/dist/corpus-loader.js +155 -0
- package/dist/corpus-loader.js.map +1 -0
- package/dist/fixture-tester.d.ts +28 -0
- package/dist/fixture-tester.d.ts.map +1 -0
- package/dist/fixture-tester.js +176 -0
- package/dist/fixture-tester.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +375 -0
- package/dist/index.js.map +1 -0
- package/dist/package-discovery.d.ts +62 -0
- package/dist/package-discovery.d.ts.map +1 -0
- package/dist/package-discovery.js +299 -0
- package/dist/package-discovery.js.map +1 -0
- package/dist/reporter.d.ts +43 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +347 -0
- package/dist/reporter.js.map +1 -0
- package/dist/reporters/benchmarking.d.ts +70 -0
- package/dist/reporters/benchmarking.d.ts.map +1 -0
- package/dist/reporters/benchmarking.js +191 -0
- package/dist/reporters/benchmarking.js.map +1 -0
- package/dist/reporters/d3-visualizer.d.ts +40 -0
- package/dist/reporters/d3-visualizer.d.ts.map +1 -0
- package/dist/reporters/d3-visualizer.js +803 -0
- package/dist/reporters/d3-visualizer.js.map +1 -0
- package/dist/reporters/health-score.d.ts +33 -0
- package/dist/reporters/health-score.d.ts.map +1 -0
- package/dist/reporters/health-score.js +149 -0
- package/dist/reporters/health-score.js.map +1 -0
- package/dist/reporters/index.d.ts +11 -0
- package/dist/reporters/index.d.ts.map +1 -0
- package/dist/reporters/index.js +11 -0
- package/dist/reporters/index.js.map +1 -0
- package/dist/reporters/package-breakdown.d.ts +48 -0
- package/dist/reporters/package-breakdown.d.ts.map +1 -0
- package/dist/reporters/package-breakdown.js +185 -0
- package/dist/reporters/package-breakdown.js.map +1 -0
- package/dist/reporters/positive-evidence.d.ts +42 -0
- package/dist/reporters/positive-evidence.d.ts.map +1 -0
- package/dist/reporters/positive-evidence.js +436 -0
- package/dist/reporters/positive-evidence.js.map +1 -0
- package/dist/tsconfig-generator.d.ts +17 -0
- package/dist/tsconfig-generator.d.ts.map +1 -0
- package/dist/tsconfig-generator.js +107 -0
- package/dist/tsconfig-generator.js.map +1 -0
- package/dist/types.d.ts +298 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,2592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST Analyzer - uses TypeScript Compiler API to detect behavioral contract violations
|
|
3
|
+
*/
|
|
4
|
+
import * as ts from 'typescript';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { ReactQueryAnalyzer } from './analyzers/react-query-analyzer.js';
|
|
7
|
+
import { AsyncErrorAnalyzer } from './analyzers/async-error-analyzer.js';
|
|
8
|
+
import { ReturnValueAnalyzer } from './analyzers/return-value-analyzer.js';
|
|
9
|
+
import { EventListenerAnalyzer } from './analyzers/event-listener-analyzer.js';
|
|
10
|
+
/**
|
|
11
|
+
* Main analyzer that coordinates the verification process
|
|
12
|
+
*/
|
|
13
|
+
export class Analyzer {
|
|
14
|
+
program;
|
|
15
|
+
typeChecker;
|
|
16
|
+
contracts;
|
|
17
|
+
violations = [];
|
|
18
|
+
projectRoot;
|
|
19
|
+
includeTests;
|
|
20
|
+
// Detection maps built dynamically from contract definitions
|
|
21
|
+
typeToPackage;
|
|
22
|
+
classToPackage;
|
|
23
|
+
factoryToPackage;
|
|
24
|
+
awaitPatternToPackage;
|
|
25
|
+
constructor(config, contracts) {
|
|
26
|
+
this.contracts = contracts;
|
|
27
|
+
this.includeTests = config.includeTests ?? false;
|
|
28
|
+
// Build detection maps from contract definitions
|
|
29
|
+
this.typeToPackage = new Map();
|
|
30
|
+
this.classToPackage = new Map();
|
|
31
|
+
this.factoryToPackage = new Map();
|
|
32
|
+
this.awaitPatternToPackage = new Map();
|
|
33
|
+
this.buildDetectionMaps();
|
|
34
|
+
// Create TypeScript program
|
|
35
|
+
const configFile = ts.readConfigFile(config.tsconfigPath, ts.sys.readFile);
|
|
36
|
+
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(config.tsconfigPath));
|
|
37
|
+
// Store project root for file system operations
|
|
38
|
+
this.projectRoot = path.dirname(config.tsconfigPath);
|
|
39
|
+
this.program = ts.createProgram({
|
|
40
|
+
rootNames: parsedConfig.fileNames,
|
|
41
|
+
options: parsedConfig.options,
|
|
42
|
+
});
|
|
43
|
+
// Initialize type checker for type-aware detection
|
|
44
|
+
this.typeChecker = this.program.getTypeChecker();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Builds detection maps from contract definitions
|
|
48
|
+
* This replaces hardcoded mappings with data-driven approach
|
|
49
|
+
*/
|
|
50
|
+
buildDetectionMaps() {
|
|
51
|
+
for (const [packageName, contract] of this.contracts.entries()) {
|
|
52
|
+
const detection = contract.detection;
|
|
53
|
+
if (!detection)
|
|
54
|
+
continue;
|
|
55
|
+
// Map class names for new expressions (e.g., new Octokit())
|
|
56
|
+
for (const className of detection.class_names || []) {
|
|
57
|
+
this.classToPackage.set(className, packageName);
|
|
58
|
+
}
|
|
59
|
+
// Map type names for type declarations (e.g., client: Octokit)
|
|
60
|
+
for (const typeName of detection.type_names || []) {
|
|
61
|
+
this.typeToPackage.set(typeName, packageName);
|
|
62
|
+
}
|
|
63
|
+
// Map factory methods (e.g., createClient())
|
|
64
|
+
for (const factoryMethod of detection.factory_methods || []) {
|
|
65
|
+
this.factoryToPackage.set(factoryMethod, packageName);
|
|
66
|
+
}
|
|
67
|
+
// Map await patterns (e.g., .repos., .pulls.)
|
|
68
|
+
for (const pattern of detection.await_patterns || []) {
|
|
69
|
+
this.awaitPatternToPackage.set(pattern.toLowerCase(), packageName);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Analyzes all files in the program and returns violations
|
|
75
|
+
*/
|
|
76
|
+
analyze() {
|
|
77
|
+
this.violations = [];
|
|
78
|
+
for (const sourceFile of this.program.getSourceFiles()) {
|
|
79
|
+
// Skip declaration files and node_modules
|
|
80
|
+
if (sourceFile.isDeclarationFile || sourceFile.fileName.includes('node_modules')) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Skip test files unless explicitly included
|
|
84
|
+
if (!this.includeTests && this.isTestFile(sourceFile.fileName)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
this.analyzeFile(sourceFile);
|
|
88
|
+
}
|
|
89
|
+
return this.violations;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Extracts all package imports from a source file
|
|
93
|
+
*/
|
|
94
|
+
extractImports(sourceFile) {
|
|
95
|
+
const imports = new Set();
|
|
96
|
+
for (const statement of sourceFile.statements) {
|
|
97
|
+
if (ts.isImportDeclaration(statement)) {
|
|
98
|
+
const moduleSpecifier = statement.moduleSpecifier;
|
|
99
|
+
if (ts.isStringLiteral(moduleSpecifier)) {
|
|
100
|
+
const packageName = moduleSpecifier.text;
|
|
101
|
+
// Add the package name (handle scoped packages like @prisma/client)
|
|
102
|
+
imports.add(packageName);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Also check for require() calls
|
|
106
|
+
if (ts.isVariableStatement(statement)) {
|
|
107
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
108
|
+
if (declaration.initializer && ts.isCallExpression(declaration.initializer)) {
|
|
109
|
+
const callExpr = declaration.initializer;
|
|
110
|
+
if (ts.isIdentifier(callExpr.expression) && callExpr.expression.text === 'require') {
|
|
111
|
+
const arg = callExpr.arguments[0];
|
|
112
|
+
if (arg && ts.isStringLiteral(arg)) {
|
|
113
|
+
imports.add(arg.text);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return imports;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Determines if a file is a test file based on common patterns
|
|
124
|
+
* Test files are excluded by default because:
|
|
125
|
+
* - Tests intentionally expect errors to be thrown
|
|
126
|
+
* - Test frameworks (Jest, Vitest) handle errors automatically
|
|
127
|
+
* - 90%+ of test violations are false positives
|
|
128
|
+
*/
|
|
129
|
+
isTestFile(filePath) {
|
|
130
|
+
const testPatterns = [
|
|
131
|
+
'/__tests__/', // Jest convention
|
|
132
|
+
'/__mocks__/', // Mock files
|
|
133
|
+
'.test.ts', // Test files
|
|
134
|
+
'.spec.ts', // Spec files
|
|
135
|
+
'.test.tsx', // React test files
|
|
136
|
+
'.spec.tsx', // React spec files
|
|
137
|
+
'/tests/', // Test directories
|
|
138
|
+
'/test/', // Test directory (singular)
|
|
139
|
+
'.test.js', // JavaScript tests
|
|
140
|
+
'.spec.js', // JavaScript specs
|
|
141
|
+
];
|
|
142
|
+
return testPatterns.some(pattern => filePath.includes(pattern));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Analyzes a single source file
|
|
146
|
+
*/
|
|
147
|
+
analyzeFile(sourceFile) {
|
|
148
|
+
const self = this;
|
|
149
|
+
// Extract all imports from this file for context-aware contract application
|
|
150
|
+
const fileImports = this.extractImports(sourceFile);
|
|
151
|
+
// Track variables that are AxiosInstance objects
|
|
152
|
+
const axiosInstances = new Map(); // variableName -> packageName
|
|
153
|
+
const instancesWithInterceptors = new Set(); // variableName
|
|
154
|
+
// Track variables that are schema instances (zod, yup, etc.)
|
|
155
|
+
// Maps variable name to package name (e.g., "userSchema" -> "zod")
|
|
156
|
+
const schemaInstances = new Map();
|
|
157
|
+
// Detect global React Query error handlers once per file
|
|
158
|
+
const reactQueryAnalyzer = new ReactQueryAnalyzer(sourceFile, this.program.getTypeChecker());
|
|
159
|
+
const globalHandlers = reactQueryAnalyzer.detectGlobalHandlers(sourceFile);
|
|
160
|
+
// First pass: find all package instance declarations and interceptors
|
|
161
|
+
function findAxiosInstances(node) {
|
|
162
|
+
// Look for: const instance = axios.create(...)
|
|
163
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
164
|
+
const varName = node.name.getText(sourceFile);
|
|
165
|
+
// Check for factory methods (axios.create, etc.)
|
|
166
|
+
const packageName = self.extractPackageFromAxiosCreate(node.initializer, sourceFile);
|
|
167
|
+
if (packageName) {
|
|
168
|
+
axiosInstances.set(varName, packageName);
|
|
169
|
+
}
|
|
170
|
+
// Check for generic factory methods from detection rules (mongoose.model, etc.)
|
|
171
|
+
const genericFactoryPackage = self.extractPackageFromGenericFactory(node.initializer, sourceFile);
|
|
172
|
+
if (genericFactoryPackage) {
|
|
173
|
+
axiosInstances.set(varName, genericFactoryPackage);
|
|
174
|
+
}
|
|
175
|
+
// Check for new expressions (new PrismaClient(), new Stripe(), etc.)
|
|
176
|
+
const newPackageName = self.extractPackageFromNewExpression(node.initializer, sourceFile);
|
|
177
|
+
if (newPackageName) {
|
|
178
|
+
axiosInstances.set(varName, newPackageName);
|
|
179
|
+
}
|
|
180
|
+
// Check for schema factory methods (z.object(), z.string(), etc.)
|
|
181
|
+
const schemaPackageName = self.extractPackageFromSchemaFactory(node.initializer, sourceFile);
|
|
182
|
+
if (schemaPackageName) {
|
|
183
|
+
schemaInstances.set(varName, schemaPackageName);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Look for: this._axios = axios.create(...) or this.db = new PrismaClient()
|
|
187
|
+
if (ts.isBinaryExpression(node) &&
|
|
188
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
189
|
+
ts.isPropertyAccessExpression(node.left)) {
|
|
190
|
+
const varName = node.left.name.text;
|
|
191
|
+
// Check for factory methods
|
|
192
|
+
const packageName = self.extractPackageFromAxiosCreate(node.right, sourceFile);
|
|
193
|
+
if (packageName) {
|
|
194
|
+
axiosInstances.set(varName, packageName);
|
|
195
|
+
}
|
|
196
|
+
// Check for generic factory methods from detection rules
|
|
197
|
+
const genericFactoryPackage = self.extractPackageFromGenericFactory(node.right, sourceFile);
|
|
198
|
+
if (genericFactoryPackage) {
|
|
199
|
+
axiosInstances.set(varName, genericFactoryPackage);
|
|
200
|
+
}
|
|
201
|
+
// Check for new expressions
|
|
202
|
+
const newPackageName = self.extractPackageFromNewExpression(node.right, sourceFile);
|
|
203
|
+
if (newPackageName) {
|
|
204
|
+
axiosInstances.set(varName, newPackageName);
|
|
205
|
+
}
|
|
206
|
+
// Check for schema factory methods
|
|
207
|
+
const schemaPackageName = self.extractPackageFromSchemaFactory(node.right, sourceFile);
|
|
208
|
+
if (schemaPackageName) {
|
|
209
|
+
schemaInstances.set(varName, schemaPackageName);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Look for: private _axios: AxiosInstance or private prisma: PrismaClient
|
|
213
|
+
if (ts.isPropertyDeclaration(node) && node.type) {
|
|
214
|
+
const varName = node.name.getText(sourceFile);
|
|
215
|
+
if (ts.isTypeReferenceNode(node.type) &&
|
|
216
|
+
ts.isIdentifier(node.type.typeName)) {
|
|
217
|
+
const typeName = node.type.typeName.text;
|
|
218
|
+
// Look up package name from detection rules
|
|
219
|
+
const packageName = self.typeToPackage.get(typeName);
|
|
220
|
+
if (packageName) {
|
|
221
|
+
axiosInstances.set(varName, packageName);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Look for: constructor(private readonly prisma: PrismaService)
|
|
226
|
+
// TypeScript/NestJS pattern where constructor parameters with modifiers create implicit properties
|
|
227
|
+
if (ts.isParameter(node) &&
|
|
228
|
+
(node.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.PublicKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword)) &&
|
|
229
|
+
node.type &&
|
|
230
|
+
ts.isIdentifier(node.name)) {
|
|
231
|
+
const varName = node.name.text;
|
|
232
|
+
if (ts.isTypeReferenceNode(node.type) &&
|
|
233
|
+
ts.isIdentifier(node.type.typeName)) {
|
|
234
|
+
const typeName = node.type.typeName.text;
|
|
235
|
+
// Look up package name from detection rules
|
|
236
|
+
const packageName = self.typeToPackage.get(typeName);
|
|
237
|
+
if (packageName) {
|
|
238
|
+
axiosInstances.set(varName, packageName);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Look for: instance.interceptors.response.use(...)
|
|
243
|
+
if (ts.isCallExpression(node) &&
|
|
244
|
+
ts.isPropertyAccessExpression(node.expression)) {
|
|
245
|
+
const callText = node.expression.getText(sourceFile);
|
|
246
|
+
// Match patterns like: axiosInstance.interceptors.response.use or instance.interceptors.request.use
|
|
247
|
+
if (callText.includes('.interceptors.response.use') ||
|
|
248
|
+
callText.includes('.interceptors.request.use')) {
|
|
249
|
+
// Extract the instance variable name (first part before .interceptors)
|
|
250
|
+
const parts = callText.split('.');
|
|
251
|
+
if (parts.length >= 4) {
|
|
252
|
+
const instanceVar = parts[0];
|
|
253
|
+
instancesWithInterceptors.add(instanceVar);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
ts.forEachChild(node, findAxiosInstances);
|
|
258
|
+
}
|
|
259
|
+
findAxiosInstances(sourceFile);
|
|
260
|
+
// Async error detection pass
|
|
261
|
+
const asyncErrorAnalyzer = new AsyncErrorAnalyzer(sourceFile);
|
|
262
|
+
this.detectAsyncErrors(sourceFile, asyncErrorAnalyzer, axiosInstances, fileImports);
|
|
263
|
+
// Return value error detection pass
|
|
264
|
+
const returnValueAnalyzer = new ReturnValueAnalyzer(sourceFile, this.contracts, this.typeChecker);
|
|
265
|
+
this.detectReturnValueErrors(sourceFile, returnValueAnalyzer, fileImports);
|
|
266
|
+
// Event listener detection pass
|
|
267
|
+
const eventListenerAnalyzer = new EventListenerAnalyzer(sourceFile, this.contracts, this.typeChecker);
|
|
268
|
+
this.detectEventListenerErrors(sourceFile, eventListenerAnalyzer, fileImports);
|
|
269
|
+
function visit(node, parent) {
|
|
270
|
+
// Set parent pointer if not already set
|
|
271
|
+
if (parent && !node.parent) {
|
|
272
|
+
node.parent = parent;
|
|
273
|
+
}
|
|
274
|
+
// Look for call expressions
|
|
275
|
+
if (ts.isCallExpression(node)) {
|
|
276
|
+
self.analyzeCallExpression(node, sourceFile, axiosInstances, instancesWithInterceptors, globalHandlers, schemaInstances, fileImports);
|
|
277
|
+
}
|
|
278
|
+
// Recursively visit children, passing current node as parent
|
|
279
|
+
ts.forEachChild(node, (child) => visit(child, node));
|
|
280
|
+
}
|
|
281
|
+
visit(sourceFile);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Detects async functions with unprotected await expressions
|
|
285
|
+
*/
|
|
286
|
+
detectAsyncErrors(sourceFile, asyncErrorAnalyzer, trackedInstances, fileImports) {
|
|
287
|
+
const self = this;
|
|
288
|
+
function visitForAsyncFunctions(node) {
|
|
289
|
+
// Check if this is an async function
|
|
290
|
+
if (asyncErrorAnalyzer.isAsyncFunction(node)) {
|
|
291
|
+
const unprotectedAwaits = asyncErrorAnalyzer.findUnprotectedAwaits(node);
|
|
292
|
+
// For each unprotected await, check if any contract requires error handling
|
|
293
|
+
for (const detection of unprotectedAwaits) {
|
|
294
|
+
// Try to determine which package this await is calling
|
|
295
|
+
// This is a simplified approach - we create a violation for any unprotected await
|
|
296
|
+
// that might be calling a package function
|
|
297
|
+
const violation = self.createAsyncErrorViolation(sourceFile, detection, node, trackedInstances, fileImports);
|
|
298
|
+
if (violation) {
|
|
299
|
+
self.violations.push(violation);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Continue traversing
|
|
304
|
+
ts.forEachChild(node, visitForAsyncFunctions);
|
|
305
|
+
}
|
|
306
|
+
visitForAsyncFunctions(sourceFile);
|
|
307
|
+
// Also detect empty/ineffective catch blocks
|
|
308
|
+
const catchBlocks = asyncErrorAnalyzer.findAllCatchBlocks(sourceFile);
|
|
309
|
+
for (const catchBlock of catchBlocks) {
|
|
310
|
+
const effectiveness = asyncErrorAnalyzer.isCatchBlockEffective(catchBlock);
|
|
311
|
+
if (effectiveness.isEmpty || effectiveness.hasConsoleOnly) {
|
|
312
|
+
const violation = this.createEmptyCatchViolation(sourceFile, catchBlock, effectiveness, fileImports);
|
|
313
|
+
if (violation) {
|
|
314
|
+
this.violations.push(violation);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Detects functions with unprotected return value error checks
|
|
321
|
+
*/
|
|
322
|
+
detectReturnValueErrors(sourceFile, returnValueAnalyzer, fileImports) {
|
|
323
|
+
const self = this;
|
|
324
|
+
function visitForFunctions(node) {
|
|
325
|
+
// Check functions (including arrow functions and methods)
|
|
326
|
+
const isFunctionLike = ts.isFunctionDeclaration(node) ||
|
|
327
|
+
ts.isFunctionExpression(node) ||
|
|
328
|
+
ts.isArrowFunction(node) ||
|
|
329
|
+
ts.isMethodDeclaration(node);
|
|
330
|
+
if (isFunctionLike) {
|
|
331
|
+
const returnValueChecks = returnValueAnalyzer.analyze(node);
|
|
332
|
+
// Create violations for unprotected return value checks
|
|
333
|
+
for (const check of returnValueChecks) {
|
|
334
|
+
const violation = self.createReturnValueViolation(sourceFile, check, fileImports);
|
|
335
|
+
if (violation) {
|
|
336
|
+
self.violations.push(violation);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Continue traversing
|
|
341
|
+
ts.forEachChild(node, visitForFunctions);
|
|
342
|
+
}
|
|
343
|
+
visitForFunctions(sourceFile);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Creates a violation for unprotected return value error checks
|
|
347
|
+
*/
|
|
348
|
+
createReturnValueViolation(sourceFile, check, fileImports) {
|
|
349
|
+
// Only create violation if this package is actually imported
|
|
350
|
+
if (!fileImports.has(check.packageName)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const location = sourceFile.getLineAndCharacterOfPosition(check.declarationNode.getStart());
|
|
354
|
+
const checkLocation = check.checkNode
|
|
355
|
+
? sourceFile.getLineAndCharacterOfPosition(check.checkNode.getStart())
|
|
356
|
+
: location;
|
|
357
|
+
const description = check.checkNode
|
|
358
|
+
? `Variable '${check.variableName}' assigned from ${check.packageName}.${check.functionName}() has unprotected error check. Error handling must be in try-catch block.`
|
|
359
|
+
: `Variable '${check.variableName}' assigned from ${check.packageName}.${check.functionName}() has no error check. Return value must be checked for errors.`;
|
|
360
|
+
return {
|
|
361
|
+
id: `${check.packageName}-${check.postcondition.id}`,
|
|
362
|
+
severity: check.postcondition.severity || 'error',
|
|
363
|
+
file: sourceFile.fileName,
|
|
364
|
+
line: checkLocation.line + 1,
|
|
365
|
+
column: checkLocation.character + 1,
|
|
366
|
+
package: check.packageName,
|
|
367
|
+
function: check.functionName,
|
|
368
|
+
contract_clause: check.postcondition.id,
|
|
369
|
+
description,
|
|
370
|
+
source_doc: check.postcondition.source || '',
|
|
371
|
+
suggested_fix: check.postcondition.required_handling,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Detects instances missing required event listeners
|
|
376
|
+
*/
|
|
377
|
+
detectEventListenerErrors(sourceFile, eventListenerAnalyzer, fileImports) {
|
|
378
|
+
const self = this;
|
|
379
|
+
function visitForFunctions(node) {
|
|
380
|
+
// Check functions (including arrow functions and methods)
|
|
381
|
+
const isFunctionLike = ts.isFunctionDeclaration(node) ||
|
|
382
|
+
ts.isFunctionExpression(node) ||
|
|
383
|
+
ts.isArrowFunction(node) ||
|
|
384
|
+
ts.isMethodDeclaration(node);
|
|
385
|
+
if (isFunctionLike) {
|
|
386
|
+
const eventListenerChecks = eventListenerAnalyzer.analyze(node);
|
|
387
|
+
// Create violations for missing event listeners
|
|
388
|
+
for (const check of eventListenerChecks) {
|
|
389
|
+
const violation = self.createEventListenerViolation(sourceFile, check, fileImports);
|
|
390
|
+
if (violation) {
|
|
391
|
+
self.violations.push(violation);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Continue traversing
|
|
396
|
+
ts.forEachChild(node, visitForFunctions);
|
|
397
|
+
}
|
|
398
|
+
visitForFunctions(sourceFile);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Creates a violation for missing required event listeners
|
|
402
|
+
*/
|
|
403
|
+
createEventListenerViolation(sourceFile, check, fileImports) {
|
|
404
|
+
// Only create violation if this package is actually imported
|
|
405
|
+
if (!fileImports.has(check.packageName)) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const location = sourceFile.getLineAndCharacterOfPosition(check.declarationNode.getStart());
|
|
409
|
+
const description = `Instance '${check.variableName}' of ${check.packageName}.${check.className} is missing required '${check.missingEvent}' event listener. Unhandled events can cause crashes.`;
|
|
410
|
+
return {
|
|
411
|
+
id: `${check.packageName}-missing-${check.missingEvent}-listener`,
|
|
412
|
+
severity: check.requiredListener.severity || 'error',
|
|
413
|
+
file: sourceFile.fileName,
|
|
414
|
+
line: location.line + 1,
|
|
415
|
+
column: location.character + 1,
|
|
416
|
+
package: check.packageName,
|
|
417
|
+
function: check.className,
|
|
418
|
+
contract_clause: `missing-${check.missingEvent}-listener`,
|
|
419
|
+
description,
|
|
420
|
+
source_doc: '',
|
|
421
|
+
suggested_fix: `Add ${check.variableName}.on('${check.missingEvent}', (err) => { /* handle error */ }) to handle ${check.missingEvent} events`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Creates a violation for unprotected async calls
|
|
426
|
+
*/
|
|
427
|
+
createAsyncErrorViolation(sourceFile, detection, _functionNode, trackedInstances, fileImports) {
|
|
428
|
+
// PRIORITY 1: Type-aware detection (most accurate - uses TypeScript's type system)
|
|
429
|
+
// This eliminates false positives from pattern overlap (e.g., mongoose ".create" vs discord.js ".createInvite")
|
|
430
|
+
if (detection.node) {
|
|
431
|
+
const typeBasedPackage = this.detectPackageFromType(detection.node);
|
|
432
|
+
if (typeBasedPackage) {
|
|
433
|
+
return this.createViolationForPackage(sourceFile, detection, typeBasedPackage, fileImports);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// PRIORITY 2: Check if this await is on a tracked instance (high accuracy)
|
|
437
|
+
// Extract instance name from await expression (e.g., "this.catModel" from "await this.catModel.find()")
|
|
438
|
+
const instancePackage = this.detectPackageFromTrackedInstance(detection.awaitText, trackedInstances);
|
|
439
|
+
if (instancePackage) {
|
|
440
|
+
// Found a tracked instance - this is high-confidence detection
|
|
441
|
+
return this.createViolationForPackage(sourceFile, detection, instancePackage, fileImports);
|
|
442
|
+
}
|
|
443
|
+
// PRIORITY 3: Check data-driven detection patterns from contracts (fallback)
|
|
444
|
+
// This allows contracts to define their own detection rules without analyzer changes
|
|
445
|
+
const detectedPackage = this.detectPackageFromAwaitText(detection.awaitText);
|
|
446
|
+
if (!detectedPackage) {
|
|
447
|
+
// No package detected by patterns and no tracked instance
|
|
448
|
+
// Don't check legacy patterns to avoid false positives
|
|
449
|
+
// Instance tracking is the primary method for ORM packages
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
// Check if this package requires instance tracking
|
|
453
|
+
const contract = this.contracts.get(detectedPackage);
|
|
454
|
+
if (contract?.detection?.require_instance_tracking) {
|
|
455
|
+
// This package requires instance tracking to avoid false positives
|
|
456
|
+
// Pattern-based detection matched, but we didn't find a tracked instance
|
|
457
|
+
// This is likely a false positive (e.g., .validate() on a non-mongoose object)
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
// Package detected via contract patterns - create violation
|
|
461
|
+
// NOTE: Pattern-based detection is less accurate than type-aware or instance tracking
|
|
462
|
+
// and may produce false positives for packages with generic method names
|
|
463
|
+
return this.createViolationForPackage(sourceFile, detection, detectedPackage, fileImports);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Detects which package is being called from the await expression text
|
|
467
|
+
* Uses dynamic pattern matching from contract detection rules
|
|
468
|
+
*/
|
|
469
|
+
detectPackageFromAwaitText(awaitText) {
|
|
470
|
+
const lowerText = awaitText.toLowerCase();
|
|
471
|
+
// Collect all matching patterns with their specificity (length)
|
|
472
|
+
const matches = [];
|
|
473
|
+
for (const [pattern, packageName] of this.awaitPatternToPackage.entries()) {
|
|
474
|
+
if (lowerText.includes(pattern)) {
|
|
475
|
+
matches.push({
|
|
476
|
+
pattern,
|
|
477
|
+
packageName,
|
|
478
|
+
specificity: pattern.length, // Longer patterns are more specific
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// If no matches, return null
|
|
483
|
+
if (matches.length === 0) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
// If only one match, return it
|
|
487
|
+
if (matches.length === 1) {
|
|
488
|
+
return matches[0].packageName;
|
|
489
|
+
}
|
|
490
|
+
// Multiple matches - return the most specific (longest) pattern
|
|
491
|
+
// This prevents false positives from broad patterns like ".create"
|
|
492
|
+
// matching more specific patterns like ".createInvite"
|
|
493
|
+
matches.sort((a, b) => b.specificity - a.specificity);
|
|
494
|
+
return matches[0].packageName;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Detects package using TypeScript's type system (MOST ACCURATE METHOD)
|
|
498
|
+
*
|
|
499
|
+
* Uses TypeScript's type checker to determine which package a variable belongs to
|
|
500
|
+
* based on its type, eliminating false positives from pattern matching.
|
|
501
|
+
*
|
|
502
|
+
* Steps:
|
|
503
|
+
* 1. Get the type of the object the method is called on
|
|
504
|
+
* 2. Extract the type name (e.g., "TextChannel", "Model")
|
|
505
|
+
* 3. Look up which package defines this type
|
|
506
|
+
*
|
|
507
|
+
* Example:
|
|
508
|
+
* const channel: TextChannel = getChannel();
|
|
509
|
+
* await channel.createInvite();
|
|
510
|
+
*
|
|
511
|
+
* → channel has type TextChannel
|
|
512
|
+
* → TextChannel is from discord.js (per contract detection.type_names)
|
|
513
|
+
* → Return "discord.js"
|
|
514
|
+
*
|
|
515
|
+
* This eliminates false positives from pattern overlap:
|
|
516
|
+
* - mongoose ".create" won't match discord.js ".createInvite()"
|
|
517
|
+
* - Each type uniquely identifies its package
|
|
518
|
+
*
|
|
519
|
+
* @param node The call expression node from the AST
|
|
520
|
+
* @returns Package name if type is recognized, null otherwise
|
|
521
|
+
*/
|
|
522
|
+
detectPackageFromType(node) {
|
|
523
|
+
const expression = node.expression;
|
|
524
|
+
// Handle: object.method() - e.g., channel.createInvite()
|
|
525
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
526
|
+
const objectExpr = expression.expression;
|
|
527
|
+
// Get the type of the object
|
|
528
|
+
const type = this.typeChecker.getTypeAtLocation(objectExpr);
|
|
529
|
+
// Try multiple strategies to extract the package
|
|
530
|
+
const packageName = this.extractPackageFromType(type);
|
|
531
|
+
if (packageName) {
|
|
532
|
+
return packageName;
|
|
533
|
+
}
|
|
534
|
+
// Handle: ClassName.staticMethod() - e.g., Model.create()
|
|
535
|
+
if (ts.isIdentifier(objectExpr)) {
|
|
536
|
+
const className = objectExpr.text;
|
|
537
|
+
const packageName = this.classToPackage.get(className);
|
|
538
|
+
if (packageName) {
|
|
539
|
+
return packageName;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Extracts package name from a TypeScript type
|
|
547
|
+
* Handles edge cases: generics, aliases, unions, intersections
|
|
548
|
+
*
|
|
549
|
+
* Edge cases:
|
|
550
|
+
* 1. Type aliases: import { Client as Bot } from 'discord.js' → resolve "Bot" to "Client"
|
|
551
|
+
* 2. Generic types: Model<User> → extract base type "Model"
|
|
552
|
+
* 3. Union types: TextChannel | VoiceChannel → try all types in union
|
|
553
|
+
* 4. Intersection types: A & B → try all types in intersection
|
|
554
|
+
*
|
|
555
|
+
* @param type The TypeScript type to analyze
|
|
556
|
+
* @returns Package name if recognized, null otherwise
|
|
557
|
+
*/
|
|
558
|
+
extractPackageFromType(type) {
|
|
559
|
+
// Strategy 1: Direct symbol lookup
|
|
560
|
+
const symbol = type.getSymbol();
|
|
561
|
+
if (symbol) {
|
|
562
|
+
const typeName = symbol.getName();
|
|
563
|
+
const packageName = this.typeToPackage.get(typeName);
|
|
564
|
+
if (packageName) {
|
|
565
|
+
return packageName;
|
|
566
|
+
}
|
|
567
|
+
// EDGE CASE 1: Type aliases (e.g., import { Client as DiscordClient })
|
|
568
|
+
// Check if this symbol is an alias and resolve it
|
|
569
|
+
// Note: getAliasedSymbol should only be called on alias symbols
|
|
570
|
+
if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) {
|
|
571
|
+
const aliasedSymbol = this.typeChecker.getAliasedSymbol(symbol);
|
|
572
|
+
if (aliasedSymbol && aliasedSymbol !== symbol) {
|
|
573
|
+
const aliasedName = aliasedSymbol.getName();
|
|
574
|
+
const aliasedPackage = this.typeToPackage.get(aliasedName);
|
|
575
|
+
if (aliasedPackage) {
|
|
576
|
+
return aliasedPackage;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// EDGE CASE 2: Generic types (e.g., Model<User>)
|
|
582
|
+
// Check if this is a type reference with type arguments
|
|
583
|
+
if (type.aliasSymbol) {
|
|
584
|
+
const aliasName = type.aliasSymbol.getName();
|
|
585
|
+
const aliasPackage = this.typeToPackage.get(aliasName);
|
|
586
|
+
if (aliasPackage) {
|
|
587
|
+
return aliasPackage;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// EDGE CASE 3: Union types (e.g., TextChannel | VoiceChannel)
|
|
591
|
+
// If multiple types, try each one and return first match
|
|
592
|
+
if (type.isUnion()) {
|
|
593
|
+
for (const unionType of type.types) {
|
|
594
|
+
const packageName = this.extractPackageFromType(unionType);
|
|
595
|
+
if (packageName) {
|
|
596
|
+
return packageName; // Return first matching type in union
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// EDGE CASE 4: Intersection types (e.g., A & B)
|
|
601
|
+
// Less common but worth handling
|
|
602
|
+
if (type.isIntersection && type.isIntersection()) {
|
|
603
|
+
for (const intersectionType of type.types) {
|
|
604
|
+
const packageName = this.extractPackageFromType(intersectionType);
|
|
605
|
+
if (packageName) {
|
|
606
|
+
return packageName; // Return first matching type in intersection
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Detects package from tracked instance (most accurate method)
|
|
614
|
+
* Extracts instance name from await expression and checks if it's tracked
|
|
615
|
+
*
|
|
616
|
+
* Examples:
|
|
617
|
+
* "await this.catModel.find()" → extracts "catModel" → checks if tracked
|
|
618
|
+
* "await user.save()" → extracts "user" → checks if tracked
|
|
619
|
+
* "await Model.create()" → extracts "Model" → checks if tracked
|
|
620
|
+
*/
|
|
621
|
+
detectPackageFromTrackedInstance(awaitText, trackedInstances) {
|
|
622
|
+
// Remove "await " prefix if present
|
|
623
|
+
const text = awaitText.replace(/^await\s+/, '');
|
|
624
|
+
// Extract instance name patterns:
|
|
625
|
+
// 1. "this.instanceName.method()" → "instanceName"
|
|
626
|
+
// 2. "instanceName.method()" → "instanceName"
|
|
627
|
+
// 3. "ClassName.staticMethod()" → "ClassName"
|
|
628
|
+
const patterns = [
|
|
629
|
+
// Match: this.instanceName.anything
|
|
630
|
+
/^this\.(\w+)\./,
|
|
631
|
+
// Match: instanceName.anything
|
|
632
|
+
/^(\w+)\./,
|
|
633
|
+
// Match: standalone identifier (for direct calls)
|
|
634
|
+
/^(\w+)\(/,
|
|
635
|
+
];
|
|
636
|
+
for (const pattern of patterns) {
|
|
637
|
+
const match = text.match(pattern);
|
|
638
|
+
if (match && match[1]) {
|
|
639
|
+
const instanceName = match[1];
|
|
640
|
+
const packageName = trackedInstances.get(instanceName);
|
|
641
|
+
if (packageName) {
|
|
642
|
+
// Found a tracked instance - high confidence detection
|
|
643
|
+
return packageName;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Creates a violation for a detected package
|
|
651
|
+
* Extracted from createAsyncErrorViolation to reduce duplication
|
|
652
|
+
*/
|
|
653
|
+
createViolationForPackage(sourceFile, detection, packageName, fileImports) {
|
|
654
|
+
// Context-aware contract application: only apply if package is imported
|
|
655
|
+
if (!fileImports.has(packageName)) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
// Get the contract for this package
|
|
659
|
+
const contract = this.contracts.get(packageName);
|
|
660
|
+
if (!contract) {
|
|
661
|
+
return null; // No contract for this package
|
|
662
|
+
}
|
|
663
|
+
// Find the matching function and postcondition
|
|
664
|
+
let matchingPostcondition;
|
|
665
|
+
let matchingFunctionName;
|
|
666
|
+
for (const func of contract.functions) {
|
|
667
|
+
// Check if any postcondition mentions async errors or matches the pattern
|
|
668
|
+
const asyncErrorPostcondition = func.postconditions?.find(pc => pc.id?.includes('no-try-catch') ||
|
|
669
|
+
pc.id?.includes('async') ||
|
|
670
|
+
pc.id?.includes('unhandled'));
|
|
671
|
+
if (asyncErrorPostcondition) {
|
|
672
|
+
matchingPostcondition = asyncErrorPostcondition;
|
|
673
|
+
matchingFunctionName = func.name;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// If we found a matching postcondition, create a violation
|
|
678
|
+
if (matchingPostcondition && matchingFunctionName) {
|
|
679
|
+
const description = `Async function '${detection.functionName}' contains unprotected await expression. ${detection.awaitText.substring(0, 50)}... may throw unhandled errors.`;
|
|
680
|
+
return {
|
|
681
|
+
id: `${packageName}-${matchingPostcondition.id}`,
|
|
682
|
+
severity: matchingPostcondition.severity || 'error',
|
|
683
|
+
file: sourceFile.fileName,
|
|
684
|
+
line: detection.line,
|
|
685
|
+
column: detection.column,
|
|
686
|
+
package: packageName,
|
|
687
|
+
function: matchingFunctionName,
|
|
688
|
+
contract_clause: matchingPostcondition.id,
|
|
689
|
+
description,
|
|
690
|
+
source_doc: matchingPostcondition.source,
|
|
691
|
+
suggested_fix: matchingPostcondition.required_handling,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Creates a violation for empty or ineffective catch blocks
|
|
698
|
+
*/
|
|
699
|
+
createEmptyCatchViolation(sourceFile, catchBlock, effectiveness, fileImports) {
|
|
700
|
+
// Look for contracts with empty-catch-block postconditions
|
|
701
|
+
let matchingPostcondition;
|
|
702
|
+
let matchingPackageName;
|
|
703
|
+
let matchingFunctionName;
|
|
704
|
+
for (const [packageName, contract] of this.contracts.entries()) {
|
|
705
|
+
// Context-aware contract application: only apply if package is imported
|
|
706
|
+
if (!fileImports.has(packageName)) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
for (const func of contract.functions) {
|
|
710
|
+
const emptyCatchPostcondition = func.postconditions?.find(pc => pc.id?.includes('empty-catch') || pc.id?.includes('silent-failure'));
|
|
711
|
+
if (emptyCatchPostcondition) {
|
|
712
|
+
matchingPostcondition = emptyCatchPostcondition;
|
|
713
|
+
matchingPackageName = packageName;
|
|
714
|
+
matchingFunctionName = func.name;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (matchingPostcondition)
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (!matchingPostcondition || !matchingPackageName || !matchingFunctionName) {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
const location = sourceFile.getLineAndCharacterOfPosition(catchBlock.getStart());
|
|
725
|
+
const description = effectiveness.isEmpty
|
|
726
|
+
? 'Empty catch block - errors are silently swallowed. Users receive no feedback when operations fail.'
|
|
727
|
+
: 'Catch block only logs to console without user feedback. Consider using toast.error() or setError().';
|
|
728
|
+
return {
|
|
729
|
+
id: `${matchingPackageName}-${matchingPostcondition.id}`,
|
|
730
|
+
severity: effectiveness.isEmpty ? 'error' : 'warning',
|
|
731
|
+
file: sourceFile.fileName,
|
|
732
|
+
line: location.line + 1,
|
|
733
|
+
column: location.character + 1,
|
|
734
|
+
package: matchingPackageName,
|
|
735
|
+
function: matchingFunctionName,
|
|
736
|
+
contract_clause: matchingPostcondition.id,
|
|
737
|
+
description,
|
|
738
|
+
source_doc: matchingPostcondition.source,
|
|
739
|
+
suggested_fix: matchingPostcondition.required_handling,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Analyzes a call expression to see if it violates any contracts
|
|
744
|
+
*/
|
|
745
|
+
analyzeCallExpression(node, sourceFile, axiosInstances, instancesWithInterceptors, globalHandlers, schemaInstances, fileImports) {
|
|
746
|
+
// Check if this is a React Query hook
|
|
747
|
+
const reactQueryAnalyzer = new ReactQueryAnalyzer(sourceFile, this.program.getTypeChecker());
|
|
748
|
+
const hookName = reactQueryAnalyzer.isReactQueryHook(node);
|
|
749
|
+
if (hookName) {
|
|
750
|
+
this.analyzeReactQueryHook(node, sourceFile, hookName, reactQueryAnalyzer, globalHandlers);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// Special handling for AWS SDK S3 send() method
|
|
754
|
+
// Pattern: s3Client.send(new GetObjectCommand(...))
|
|
755
|
+
const s3Analysis = this.analyzeS3SendCall(node, sourceFile, axiosInstances);
|
|
756
|
+
if (s3Analysis) {
|
|
757
|
+
this.analyzeS3Command(s3Analysis, node, sourceFile);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const callSite = this.extractCallSite(node, sourceFile, axiosInstances, schemaInstances);
|
|
761
|
+
if (!callSite)
|
|
762
|
+
return;
|
|
763
|
+
const contract = this.contracts.get(callSite.packageName);
|
|
764
|
+
if (!contract)
|
|
765
|
+
return;
|
|
766
|
+
// Context-aware contract application: only apply if package is imported
|
|
767
|
+
if (!fileImports.has(callSite.packageName)) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
// NEW: Handle namespace methods
|
|
771
|
+
// Check if this call has a namespace (e.g., ts.sys.readFile())
|
|
772
|
+
const namespace = node.__namespace;
|
|
773
|
+
// Match function contract, considering namespace if present
|
|
774
|
+
const functionContract = contract.functions.find(f => {
|
|
775
|
+
// If the call has a namespace, match both namespace and function name
|
|
776
|
+
if (namespace) {
|
|
777
|
+
return f.namespace === namespace && f.name === callSite.functionName;
|
|
778
|
+
}
|
|
779
|
+
// Otherwise, match function name only (and ensure it's not a namespaced function)
|
|
780
|
+
return f.name === callSite.functionName && !f.namespace;
|
|
781
|
+
});
|
|
782
|
+
if (!functionContract)
|
|
783
|
+
return;
|
|
784
|
+
// Check if this call is on an instance with error interceptors
|
|
785
|
+
const instanceVar = this.extractInstanceVariable(node, sourceFile);
|
|
786
|
+
const hasGlobalInterceptor = instanceVar ? instancesWithInterceptors.has(instanceVar) : false;
|
|
787
|
+
// Analyze what error handling exists at this call site
|
|
788
|
+
const analysis = this.analyzeErrorHandling(node, sourceFile, hasGlobalInterceptor);
|
|
789
|
+
// Check each postcondition
|
|
790
|
+
for (const postcondition of functionContract.postconditions || []) {
|
|
791
|
+
if (postcondition.severity !== 'error')
|
|
792
|
+
continue;
|
|
793
|
+
if (!postcondition.required_handling)
|
|
794
|
+
continue;
|
|
795
|
+
const violation = this.checkPostcondition(callSite, postcondition, analysis, contract.package, functionContract.name, node, sourceFile);
|
|
796
|
+
if (violation) {
|
|
797
|
+
this.violations.push(violation);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Analyzes React Query hooks for error handling
|
|
803
|
+
*/
|
|
804
|
+
analyzeReactQueryHook(node, sourceFile, hookName, reactQueryAnalyzer, globalHandlers) {
|
|
805
|
+
// Check if we have a contract for React Query
|
|
806
|
+
const contract = this.contracts.get('@tanstack/react-query');
|
|
807
|
+
if (!contract)
|
|
808
|
+
return;
|
|
809
|
+
// Find the function contract for this hook
|
|
810
|
+
const functionContract = contract.functions.find(f => f.name === hookName);
|
|
811
|
+
if (!functionContract)
|
|
812
|
+
return;
|
|
813
|
+
// Extract hook call information
|
|
814
|
+
const hookCall = reactQueryAnalyzer.extractHookCall(node, hookName);
|
|
815
|
+
if (!hookCall)
|
|
816
|
+
return;
|
|
817
|
+
// Find the containing component
|
|
818
|
+
const componentNode = reactQueryAnalyzer.findContainingComponent(node);
|
|
819
|
+
if (!componentNode)
|
|
820
|
+
return;
|
|
821
|
+
// Check for deferred error handling (mutateAsync with try-catch)
|
|
822
|
+
let hasDeferredErrorHandling = false;
|
|
823
|
+
if (hookName === 'useMutation') {
|
|
824
|
+
// Check if this mutation is assigned to a variable and later used with try-catch
|
|
825
|
+
const parent = node.parent;
|
|
826
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
827
|
+
const mutationVarName = parent.name.text;
|
|
828
|
+
hasDeferredErrorHandling = this.checkMutateAsyncInTryCatch(mutationVarName, componentNode, sourceFile);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Analyze error handling
|
|
832
|
+
const errorHandling = reactQueryAnalyzer.analyzeHookErrorHandling(hookCall, componentNode);
|
|
833
|
+
// Credit global handlers if they exist
|
|
834
|
+
if (hookName === 'useQuery' || hookName === 'useInfiniteQuery') {
|
|
835
|
+
if (globalHandlers.hasQueryCacheOnError) {
|
|
836
|
+
errorHandling.hasGlobalHandler = true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else if (hookName === 'useMutation') {
|
|
840
|
+
if (globalHandlers.hasMutationCacheOnError) {
|
|
841
|
+
errorHandling.hasGlobalHandler = true;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Check postconditions
|
|
845
|
+
for (const postcondition of functionContract.postconditions || []) {
|
|
846
|
+
const violation = this.checkReactQueryPostcondition(hookCall, errorHandling, postcondition, contract.package, functionContract.name, hasDeferredErrorHandling);
|
|
847
|
+
if (violation) {
|
|
848
|
+
this.violations.push(violation);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Checks if a mutation variable is used with mutateAsync in a try-catch block
|
|
854
|
+
*/
|
|
855
|
+
checkMutateAsyncInTryCatch(mutationVarName, componentNode, sourceFile) {
|
|
856
|
+
let foundInTryCatch = false;
|
|
857
|
+
const visit = (node) => {
|
|
858
|
+
// Look for: mutation.mutateAsync(...) or await mutation.mutateAsync(...)
|
|
859
|
+
if (ts.isCallExpression(node)) {
|
|
860
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
861
|
+
const objName = node.expression.expression.getText(sourceFile);
|
|
862
|
+
const methodName = node.expression.name.text;
|
|
863
|
+
if (objName === mutationVarName && methodName === 'mutateAsync') {
|
|
864
|
+
// Check if this call is inside a try-catch
|
|
865
|
+
if (this.isInTryCatch(node)) {
|
|
866
|
+
foundInTryCatch = true;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
ts.forEachChild(node, visit);
|
|
872
|
+
};
|
|
873
|
+
visit(componentNode);
|
|
874
|
+
return foundInTryCatch;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Checks a React Query postcondition and returns a violation if not met
|
|
878
|
+
*/
|
|
879
|
+
checkReactQueryPostcondition(hookCall, errorHandling, postcondition, packageName, functionName, hasDeferredErrorHandling = false) {
|
|
880
|
+
// Only check error severity postconditions
|
|
881
|
+
if (postcondition.severity !== 'error')
|
|
882
|
+
return null;
|
|
883
|
+
const clauseId = postcondition.id;
|
|
884
|
+
// Check query-error-unhandled
|
|
885
|
+
if (clauseId === 'query-error-unhandled' ||
|
|
886
|
+
clauseId === 'mutation-error-unhandled' ||
|
|
887
|
+
clauseId === 'infinite-query-error-unhandled') {
|
|
888
|
+
// Error is handled if ANY of these are true:
|
|
889
|
+
// 1. Error state is checked (isError, error)
|
|
890
|
+
// 2. onError callback is provided
|
|
891
|
+
// 3. Global error handler is configured
|
|
892
|
+
// 4. Deferred error handling (mutateAsync + try-catch)
|
|
893
|
+
if (errorHandling.hasErrorStateCheck ||
|
|
894
|
+
errorHandling.hasOnErrorCallback ||
|
|
895
|
+
errorHandling.hasGlobalHandler ||
|
|
896
|
+
hasDeferredErrorHandling) {
|
|
897
|
+
return null; // No violation
|
|
898
|
+
}
|
|
899
|
+
// Create violation
|
|
900
|
+
return {
|
|
901
|
+
id: `${packageName}-${clauseId}`,
|
|
902
|
+
severity: postcondition.severity,
|
|
903
|
+
file: hookCall.location.file,
|
|
904
|
+
line: hookCall.location.line,
|
|
905
|
+
column: hookCall.location.column,
|
|
906
|
+
package: packageName,
|
|
907
|
+
function: functionName,
|
|
908
|
+
contract_clause: clauseId,
|
|
909
|
+
description: 'No error handling found. Errors will crash the application.',
|
|
910
|
+
source_doc: postcondition.source,
|
|
911
|
+
suggested_fix: postcondition.required_handling,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// Check mutation-optimistic-update-rollback
|
|
915
|
+
if (clauseId === 'mutation-optimistic-update-rollback') {
|
|
916
|
+
if (hookCall.options.onMutate && !hookCall.options.onError) {
|
|
917
|
+
return {
|
|
918
|
+
id: `${packageName}-${clauseId}`,
|
|
919
|
+
severity: postcondition.severity,
|
|
920
|
+
file: hookCall.location.file,
|
|
921
|
+
line: hookCall.location.line,
|
|
922
|
+
column: hookCall.location.column,
|
|
923
|
+
package: packageName,
|
|
924
|
+
function: functionName,
|
|
925
|
+
contract_clause: clauseId,
|
|
926
|
+
description: 'Optimistic update without rollback. UI will show incorrect data on error.',
|
|
927
|
+
source_doc: postcondition.source,
|
|
928
|
+
suggested_fix: postcondition.required_handling,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Walks up a property access chain and returns components
|
|
936
|
+
* Example: prisma.user.create → { root: 'prisma', chain: ['user'], method: 'create' }
|
|
937
|
+
* Example: axios.get → { root: 'axios', chain: [], method: 'get' }
|
|
938
|
+
* Example: openai.chat.completions.create → { root: 'openai', chain: ['chat', 'completions'], method: 'create' }
|
|
939
|
+
*/
|
|
940
|
+
walkPropertyAccessChain(expr, _sourceFile) {
|
|
941
|
+
const chain = [];
|
|
942
|
+
let current = expr.expression;
|
|
943
|
+
// Walk up the chain, collecting property names
|
|
944
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
945
|
+
chain.unshift(current.name.text); // Add to front to maintain order
|
|
946
|
+
current = current.expression;
|
|
947
|
+
}
|
|
948
|
+
// NEW: Handle builder patterns - walk through call expressions
|
|
949
|
+
// Example: supabase.from('users').select()
|
|
950
|
+
// - current is now: from('users') [CallExpression]
|
|
951
|
+
// - need to walk through it to reach 'supabase' [Identifier]
|
|
952
|
+
while (ts.isCallExpression(current)) {
|
|
953
|
+
if (ts.isPropertyAccessExpression(current.expression)) {
|
|
954
|
+
// The call is on a property access (e.g., supabase.from)
|
|
955
|
+
// Add the method name to the chain
|
|
956
|
+
chain.unshift(current.expression.name.text);
|
|
957
|
+
current = current.expression.expression;
|
|
958
|
+
// Continue walking through any additional property accesses
|
|
959
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
960
|
+
chain.unshift(current.name.text);
|
|
961
|
+
current = current.expression;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
// Handle factory function pattern: sharp().toFile()
|
|
966
|
+
// Check if the call expression is on a simple identifier (e.g., sharp)
|
|
967
|
+
if (ts.isIdentifier(current.expression)) {
|
|
968
|
+
// Found root identifier, use it as the root
|
|
969
|
+
current = current.expression;
|
|
970
|
+
break; // Exit with the identifier
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
// Unsupported pattern (e.g., complex expression)
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// At this point, current should be the root identifier
|
|
979
|
+
if (!ts.isIdentifier(current)) {
|
|
980
|
+
return null; // Unsupported pattern (e.g., complex expression)
|
|
981
|
+
}
|
|
982
|
+
const root = current.text;
|
|
983
|
+
const method = expr.name.text;
|
|
984
|
+
return { root, chain, method };
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Extracts call site information from a call expression
|
|
988
|
+
*/
|
|
989
|
+
extractCallSite(node, sourceFile, axiosInstances, schemaInstances) {
|
|
990
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
991
|
+
// Try to determine the function and package being called
|
|
992
|
+
let functionName = null;
|
|
993
|
+
let packageName = null;
|
|
994
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
995
|
+
// Walk the full property access chain to handle both simple and chained calls
|
|
996
|
+
// Simple: axios.get() → { root: 'axios', chain: [], method: 'get' }
|
|
997
|
+
// Chained: prisma.user.create() → { root: 'prisma', chain: ['user'], method: 'create' }
|
|
998
|
+
// Property: this.prisma.user.create() → { root: 'this', chain: ['prisma', 'user'], method: 'create' }
|
|
999
|
+
// Namespace: ts.sys.readFile() → { root: 'ts', chain: ['sys'], method: 'readFile' }
|
|
1000
|
+
const chainInfo = this.walkPropertyAccessChain(node.expression, sourceFile);
|
|
1001
|
+
if (chainInfo) {
|
|
1002
|
+
functionName = chainInfo.method;
|
|
1003
|
+
let rootIdentifier = chainInfo.root;
|
|
1004
|
+
// Special handling for 'this.property' patterns
|
|
1005
|
+
if (rootIdentifier === 'this' && chainInfo.chain.length > 0) {
|
|
1006
|
+
// For this.prisma.user.create(), use 'prisma' as the identifier
|
|
1007
|
+
rootIdentifier = chainInfo.chain[0];
|
|
1008
|
+
// Remove first element from chain since we're using it as root
|
|
1009
|
+
chainInfo.chain = chainInfo.chain.slice(1);
|
|
1010
|
+
}
|
|
1011
|
+
// Check if root is a direct package name
|
|
1012
|
+
if (this.contracts.has(rootIdentifier)) {
|
|
1013
|
+
packageName = rootIdentifier;
|
|
1014
|
+
}
|
|
1015
|
+
// Check if root is a known instance variable (e.g., axiosInstance, prismaClient)
|
|
1016
|
+
else if (axiosInstances.has(rootIdentifier)) {
|
|
1017
|
+
packageName = axiosInstances.get(rootIdentifier);
|
|
1018
|
+
}
|
|
1019
|
+
// Check if root is a tracked schema instance (e.g., userSchema created from z.object())
|
|
1020
|
+
else if (schemaInstances.has(rootIdentifier)) {
|
|
1021
|
+
packageName = schemaInstances.get(rootIdentifier);
|
|
1022
|
+
}
|
|
1023
|
+
// Fallback: resolve from imports
|
|
1024
|
+
else {
|
|
1025
|
+
packageName = this.resolvePackageFromImports(rootIdentifier, sourceFile);
|
|
1026
|
+
}
|
|
1027
|
+
// NEW: Handle namespace methods
|
|
1028
|
+
// For patterns like ts.sys.readFile() where:
|
|
1029
|
+
// - root = 'ts' (namespace import alias)
|
|
1030
|
+
// - chain = ['sys'] (namespace within the package)
|
|
1031
|
+
// - method = 'readFile' (function name)
|
|
1032
|
+
// We need to check if there's a contract for this namespace method
|
|
1033
|
+
if (packageName && chainInfo.chain.length > 0) {
|
|
1034
|
+
const namespace = chainInfo.chain[0];
|
|
1035
|
+
const contract = this.contracts.get(packageName);
|
|
1036
|
+
if (contract) {
|
|
1037
|
+
// Check if any function in this contract has a matching namespace
|
|
1038
|
+
const namespacedFunction = contract.functions.find(f => f.namespace === namespace && f.name === functionName);
|
|
1039
|
+
// If we found a namespaced function, we'll use it
|
|
1040
|
+
// The functionName stays as the method (e.g., 'readFile')
|
|
1041
|
+
// The chain info will help us match it later
|
|
1042
|
+
if (namespacedFunction) {
|
|
1043
|
+
// Store the namespace info for later matching
|
|
1044
|
+
// We'll use this in analyzeCallExpression
|
|
1045
|
+
node.__namespace = namespace;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
else if (ts.isIdentifier(node.expression)) {
|
|
1052
|
+
// get(...) pattern after import
|
|
1053
|
+
functionName = node.expression.text;
|
|
1054
|
+
}
|
|
1055
|
+
if (!functionName)
|
|
1056
|
+
return null;
|
|
1057
|
+
// Try to resolve package name from imports
|
|
1058
|
+
if (!packageName) {
|
|
1059
|
+
packageName = this.resolvePackageFromImports(functionName, sourceFile);
|
|
1060
|
+
}
|
|
1061
|
+
if (!packageName || !this.contracts.has(packageName)) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
file: sourceFile.fileName,
|
|
1066
|
+
line: line + 1,
|
|
1067
|
+
column: character + 1,
|
|
1068
|
+
functionName,
|
|
1069
|
+
packageName,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Extracts the instance variable name from a call expression
|
|
1074
|
+
* e.g., for "axiosInstance.get(...)" returns "axiosInstance"
|
|
1075
|
+
*/
|
|
1076
|
+
extractInstanceVariable(node, _sourceFile) {
|
|
1077
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
1078
|
+
if (ts.isIdentifier(node.expression.expression)) {
|
|
1079
|
+
// Pattern: instance.get(...) - direct identifier
|
|
1080
|
+
return node.expression.expression.text;
|
|
1081
|
+
}
|
|
1082
|
+
else if (ts.isPropertyAccessExpression(node.expression.expression)) {
|
|
1083
|
+
// Pattern: this._axios.get(...) or obj.instance.get(...)
|
|
1084
|
+
return node.expression.expression.name.text;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Resolves which package a function comes from by looking at imports
|
|
1091
|
+
*/
|
|
1092
|
+
resolvePackageFromImports(functionName, sourceFile) {
|
|
1093
|
+
for (const statement of sourceFile.statements) {
|
|
1094
|
+
if (ts.isImportDeclaration(statement)) {
|
|
1095
|
+
const moduleSpecifier = statement.moduleSpecifier;
|
|
1096
|
+
if (ts.isStringLiteral(moduleSpecifier)) {
|
|
1097
|
+
const importPath = moduleSpecifier.text;
|
|
1098
|
+
let packageName = importPath;
|
|
1099
|
+
// Handle subpath exports: @clerk/nextjs/server -> @clerk/nextjs
|
|
1100
|
+
// Check if the import path has a contract, if not try the parent package
|
|
1101
|
+
if (!this.contracts.has(packageName)) {
|
|
1102
|
+
// Try removing subpath to find parent package
|
|
1103
|
+
// e.g., "@clerk/nextjs/server" -> "@clerk/nextjs"
|
|
1104
|
+
const lastSlash = importPath.lastIndexOf('/');
|
|
1105
|
+
if (lastSlash > 0 && importPath.startsWith('@')) {
|
|
1106
|
+
// For scoped packages, only remove after the package name
|
|
1107
|
+
const firstSlash = importPath.indexOf('/');
|
|
1108
|
+
if (lastSlash > firstSlash) {
|
|
1109
|
+
const parentPackage = importPath.substring(0, lastSlash);
|
|
1110
|
+
if (this.contracts.has(parentPackage)) {
|
|
1111
|
+
packageName = parentPackage;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
if (!this.contracts.has(packageName))
|
|
1117
|
+
continue;
|
|
1118
|
+
// Check if this import includes our function
|
|
1119
|
+
const importClause = statement.importClause;
|
|
1120
|
+
if (!importClause)
|
|
1121
|
+
continue;
|
|
1122
|
+
// Handle: import axios from 'axios'
|
|
1123
|
+
if (importClause.name?.text === functionName) {
|
|
1124
|
+
return packageName;
|
|
1125
|
+
}
|
|
1126
|
+
// Handle: import { get } from 'axios'
|
|
1127
|
+
if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
|
|
1128
|
+
for (const element of importClause.namedBindings.elements) {
|
|
1129
|
+
if (element.name.text === functionName) {
|
|
1130
|
+
return packageName;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Handle: import * as ts from 'typescript'
|
|
1135
|
+
// NEW: Namespace imports support for packages with namespace methods
|
|
1136
|
+
if (importClause.namedBindings && ts.isNamespaceImport(importClause.namedBindings)) {
|
|
1137
|
+
const namespaceAlias = importClause.namedBindings.name.text;
|
|
1138
|
+
if (namespaceAlias === functionName) {
|
|
1139
|
+
return packageName;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Extracts package name from new expressions
|
|
1149
|
+
* Examples: new PrismaClient() → "@prisma/client"
|
|
1150
|
+
* new Stripe(key) → "stripe"
|
|
1151
|
+
* new OpenAI(config) → "openai"
|
|
1152
|
+
*/
|
|
1153
|
+
extractPackageFromNewExpression(node, sourceFile) {
|
|
1154
|
+
if (!ts.isNewExpression(node))
|
|
1155
|
+
return null;
|
|
1156
|
+
const className = node.expression.getText(sourceFile);
|
|
1157
|
+
// Look up package name from detection rules
|
|
1158
|
+
const packageName = this.classToPackage.get(className);
|
|
1159
|
+
if (packageName) {
|
|
1160
|
+
return packageName;
|
|
1161
|
+
}
|
|
1162
|
+
// Fallback: resolve from imports
|
|
1163
|
+
return this.resolvePackageFromImports(className, sourceFile);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Extracts package name from axios.create() call
|
|
1167
|
+
* Returns the package name if this is an axios.create() or similar factory call
|
|
1168
|
+
*/
|
|
1169
|
+
extractPackageFromAxiosCreate(node, sourceFile) {
|
|
1170
|
+
// Pattern 1: axios.create(...)
|
|
1171
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1172
|
+
const methodName = node.expression.name.text;
|
|
1173
|
+
// Check if this is a factory method (create, default, etc.)
|
|
1174
|
+
if (methodName === 'create' || methodName === 'default') {
|
|
1175
|
+
if (ts.isIdentifier(node.expression.expression)) {
|
|
1176
|
+
const objectName = node.expression.expression.text;
|
|
1177
|
+
// Check if this is from a package we track
|
|
1178
|
+
const packageName = this.resolvePackageFromImports(objectName, sourceFile);
|
|
1179
|
+
if (packageName) {
|
|
1180
|
+
return packageName;
|
|
1181
|
+
}
|
|
1182
|
+
// Direct match (e.g., axios.create where axios is imported as 'axios')
|
|
1183
|
+
if (this.contracts.has(objectName)) {
|
|
1184
|
+
return objectName;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// Pattern 2: createClient(...) - named function import
|
|
1190
|
+
// Example: import { createClient } from '@supabase/supabase-js'
|
|
1191
|
+
// const supabase = createClient(url, key)
|
|
1192
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
1193
|
+
const functionName = node.expression.text;
|
|
1194
|
+
// Check if this is a factory function (createClient, etc.)
|
|
1195
|
+
if (functionName.startsWith('create') || functionName === 'default') {
|
|
1196
|
+
// Resolve which package this function is from
|
|
1197
|
+
const packageName = this.resolvePackageFromImports(functionName, sourceFile);
|
|
1198
|
+
if (packageName && this.contracts.has(packageName)) {
|
|
1199
|
+
return packageName;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// Pattern 3: Direct package function calls
|
|
1203
|
+
// Example: import twilio from 'twilio'
|
|
1204
|
+
// const client = twilio(accountSid, authToken)
|
|
1205
|
+
// This handles packages where the default export is a function that creates a client instance
|
|
1206
|
+
const packageName = this.resolvePackageFromImports(functionName, sourceFile);
|
|
1207
|
+
if (packageName && this.contracts.has(packageName)) {
|
|
1208
|
+
return packageName;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Extracts package name from generic factory methods defined in detection rules
|
|
1215
|
+
* Examples:
|
|
1216
|
+
* mongoose.model('User', schema) → "mongoose" (if factory_methods includes "model")
|
|
1217
|
+
* prisma.client() → "prisma" (if factory_methods includes "client")
|
|
1218
|
+
*/
|
|
1219
|
+
extractPackageFromGenericFactory(node, sourceFile) {
|
|
1220
|
+
// Pattern: objectName.methodName(...)
|
|
1221
|
+
// Example: mongoose.model('User', schema)
|
|
1222
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1223
|
+
const methodName = node.expression.name.text;
|
|
1224
|
+
// Check if this method is registered as a factory method for any package
|
|
1225
|
+
const packageName = this.factoryToPackage.get(methodName);
|
|
1226
|
+
if (packageName) {
|
|
1227
|
+
// Verify the object name matches the package (or is imported from it)
|
|
1228
|
+
if (ts.isIdentifier(node.expression.expression)) {
|
|
1229
|
+
const objectName = node.expression.expression.text;
|
|
1230
|
+
// Check if this object is the package itself or imported from it
|
|
1231
|
+
const resolvedPackage = this.resolvePackageFromImports(objectName, sourceFile);
|
|
1232
|
+
if (resolvedPackage === packageName || objectName === packageName) {
|
|
1233
|
+
return packageName;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Extracts package name from schema factory methods (z.object(), z.string(), etc.)
|
|
1242
|
+
* Returns the package name if this is a schema creation call
|
|
1243
|
+
*/
|
|
1244
|
+
extractPackageFromSchemaFactory(node, sourceFile) {
|
|
1245
|
+
// Pattern: z.object(...), z.string(), z.number(), etc.
|
|
1246
|
+
// These are factory methods that return schema instances
|
|
1247
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1248
|
+
const methodName = node.expression.name.text;
|
|
1249
|
+
// Common zod schema factory methods
|
|
1250
|
+
const zodFactoryMethods = [
|
|
1251
|
+
'object', 'string', 'number', 'boolean', 'array', 'tuple',
|
|
1252
|
+
'union', 'intersection', 'record', 'map', 'set', 'date',
|
|
1253
|
+
'undefined', 'null', 'void', 'any', 'unknown', 'never',
|
|
1254
|
+
'literal', 'enum', 'nativeEnum', 'promise', 'function',
|
|
1255
|
+
'lazy', 'discriminatedUnion', 'instanceof', 'nan', 'optional',
|
|
1256
|
+
'nullable', 'coerce'
|
|
1257
|
+
];
|
|
1258
|
+
if (zodFactoryMethods.includes(methodName)) {
|
|
1259
|
+
if (ts.isIdentifier(node.expression.expression)) {
|
|
1260
|
+
const objectName = node.expression.expression.text;
|
|
1261
|
+
// Check if this is 'z' from zod import
|
|
1262
|
+
const packageName = this.resolvePackageFromImports(objectName, sourceFile);
|
|
1263
|
+
if (packageName === 'zod') {
|
|
1264
|
+
return packageName;
|
|
1265
|
+
}
|
|
1266
|
+
// Direct match if imported as something else
|
|
1267
|
+
if (objectName === 'z' || objectName === 'zod') {
|
|
1268
|
+
// Verify it's actually from zod package
|
|
1269
|
+
const resolved = this.resolvePackageFromImports(objectName, sourceFile);
|
|
1270
|
+
if (resolved) {
|
|
1271
|
+
return resolved;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
// Pattern: z.ZodObject.create(...) - less common but possible
|
|
1278
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1279
|
+
const methodName = node.expression.name.text;
|
|
1280
|
+
if (methodName === 'create' && ts.isPropertyAccessExpression(node.expression.expression)) {
|
|
1281
|
+
// Check if this is z.ZodObject.create()
|
|
1282
|
+
const className = node.expression.expression.name.text;
|
|
1283
|
+
if (className.startsWith('Zod')) {
|
|
1284
|
+
const rootExpr = node.expression.expression.expression;
|
|
1285
|
+
if (ts.isIdentifier(rootExpr)) {
|
|
1286
|
+
const packageName = this.resolvePackageFromImports(rootExpr.text, sourceFile);
|
|
1287
|
+
if (packageName === 'zod') {
|
|
1288
|
+
return packageName;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// Pattern: schema.extend(...), schema.merge(...), schema.pick(...), etc.
|
|
1295
|
+
// These also return new schema instances
|
|
1296
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1297
|
+
const methodName = node.expression.name.text;
|
|
1298
|
+
const schemaTransformMethods = [
|
|
1299
|
+
'extend', 'merge', 'pick', 'omit', 'partial', 'required',
|
|
1300
|
+
'passthrough', 'strict', 'strip', 'catchall', 'brand',
|
|
1301
|
+
'default', 'describe', 'refine', 'superRefine', 'transform',
|
|
1302
|
+
'preprocess', 'pipe', 'readonly', 'optional', 'nullable',
|
|
1303
|
+
'nullish', 'array', 'promise', 'or', 'and'
|
|
1304
|
+
];
|
|
1305
|
+
if (schemaTransformMethods.includes(methodName)) {
|
|
1306
|
+
// Check if the base is already a tracked schema
|
|
1307
|
+
// This is a bit tricky since we're in the instance detection phase
|
|
1308
|
+
// For now, we'll just check if it looks like a schema method call
|
|
1309
|
+
return null; // Will be handled by tracking the base schema
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Analyzes what error handling exists around a call site
|
|
1316
|
+
*/
|
|
1317
|
+
/**
|
|
1318
|
+
* Checks if a call is within an Express route that has error middleware
|
|
1319
|
+
* Pattern: app.post('/route', middleware, errorHandler)
|
|
1320
|
+
* where errorHandler has 4 parameters (err, req, res, next)
|
|
1321
|
+
*/
|
|
1322
|
+
isWithinExpressErrorMiddleware(node, sourceFile) {
|
|
1323
|
+
// Walk up to find if this call is an argument to an Express route method
|
|
1324
|
+
let current = node;
|
|
1325
|
+
while (current) {
|
|
1326
|
+
// Check if this is an argument to app.get/post/put/delete/etc
|
|
1327
|
+
if (ts.isCallExpression(current)) {
|
|
1328
|
+
const expr = current.expression;
|
|
1329
|
+
// Pattern: app.post(...) or router.post(...)
|
|
1330
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
1331
|
+
const methodName = expr.name.text;
|
|
1332
|
+
const objectName = ts.isIdentifier(expr.expression) ? expr.expression.text : '';
|
|
1333
|
+
// Check if this looks like an Express route registration
|
|
1334
|
+
const isExpressRoute = ['get', 'post', 'put', 'delete', 'patch', 'all', 'use'].includes(methodName) &&
|
|
1335
|
+
(objectName === 'app' || objectName === 'router' || objectName.includes('app') || objectName.includes('router'));
|
|
1336
|
+
if (isExpressRoute && current.arguments.length >= 2) {
|
|
1337
|
+
// Check the arguments - look for error handler middleware
|
|
1338
|
+
// Error handler has signature: (err, req, res, next)
|
|
1339
|
+
for (let i = 1; i < current.arguments.length; i++) {
|
|
1340
|
+
const arg = current.arguments[i];
|
|
1341
|
+
// Check if this argument is a function with 4 parameters
|
|
1342
|
+
if (ts.isFunctionExpression(arg) || ts.isArrowFunction(arg)) {
|
|
1343
|
+
if (arg.parameters.length === 4) {
|
|
1344
|
+
// This looks like an error handler middleware
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// Check if this is an identifier referencing a function
|
|
1349
|
+
if (ts.isIdentifier(arg)) {
|
|
1350
|
+
const funcName = arg.text;
|
|
1351
|
+
// Common patterns: handleError, errorHandler, handleMulterError
|
|
1352
|
+
if (funcName.toLowerCase().includes('error') || funcName.toLowerCase().includes('handler')) {
|
|
1353
|
+
// Check if we can find the function definition
|
|
1354
|
+
const funcDef = this.findFunctionDefinition(funcName, sourceFile);
|
|
1355
|
+
if (funcDef && funcDef.parameters.length === 4) {
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
current = current.parent;
|
|
1365
|
+
}
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Checks if a call is within a NestJS controller method
|
|
1370
|
+
* NestJS controllers use exception filters to handle errors globally
|
|
1371
|
+
*/
|
|
1372
|
+
isWithinNestJSController(node) {
|
|
1373
|
+
let current = node;
|
|
1374
|
+
while (current) {
|
|
1375
|
+
// Check if we're inside a class with @Controller decorator
|
|
1376
|
+
if (ts.isClassDeclaration(current)) {
|
|
1377
|
+
if (this.hasDecorator(current, 'Controller')) {
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// Check if we're inside a method with a route decorator
|
|
1382
|
+
// @Get(), @Post(), @Put(), @Delete(), @Patch()
|
|
1383
|
+
if (ts.isMethodDeclaration(current)) {
|
|
1384
|
+
const routeDecorators = ['Get', 'Post', 'Put', 'Delete', 'Patch', 'All'];
|
|
1385
|
+
for (const decoratorName of routeDecorators) {
|
|
1386
|
+
if (this.hasDecorator(current, decoratorName)) {
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
current = current.parent;
|
|
1392
|
+
}
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Checks if a node has a specific decorator
|
|
1397
|
+
*/
|
|
1398
|
+
hasDecorator(node, decoratorName) {
|
|
1399
|
+
if (!node.modifiers)
|
|
1400
|
+
return false;
|
|
1401
|
+
for (const modifier of node.modifiers) {
|
|
1402
|
+
if (ts.isDecorator(modifier)) {
|
|
1403
|
+
const expr = modifier.expression;
|
|
1404
|
+
// @Controller or @Controller('users')
|
|
1405
|
+
if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
1406
|
+
if (expr.expression.text === decoratorName) {
|
|
1407
|
+
return true;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
else if (ts.isIdentifier(expr)) {
|
|
1411
|
+
if (expr.text === decoratorName) {
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Finds a function definition by name in the source file
|
|
1421
|
+
*/
|
|
1422
|
+
findFunctionDefinition(name, sourceFile) {
|
|
1423
|
+
let foundFunction = null;
|
|
1424
|
+
const visit = (node) => {
|
|
1425
|
+
// Function declaration: function handleError(...)
|
|
1426
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.name.text === name) {
|
|
1427
|
+
foundFunction = node;
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Variable with arrow function: const handleError = (...) => {}
|
|
1431
|
+
if (ts.isVariableDeclaration(node) &&
|
|
1432
|
+
ts.isIdentifier(node.name) &&
|
|
1433
|
+
node.name.text === name &&
|
|
1434
|
+
node.initializer &&
|
|
1435
|
+
ts.isArrowFunction(node.initializer)) {
|
|
1436
|
+
foundFunction = node.initializer;
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
ts.forEachChild(node, visit);
|
|
1440
|
+
};
|
|
1441
|
+
visit(sourceFile);
|
|
1442
|
+
return foundFunction;
|
|
1443
|
+
}
|
|
1444
|
+
analyzeErrorHandling(node, sourceFile, hasGlobalInterceptor = false) {
|
|
1445
|
+
const analysis = {
|
|
1446
|
+
callSite: {
|
|
1447
|
+
file: sourceFile.fileName,
|
|
1448
|
+
line: 0,
|
|
1449
|
+
column: 0,
|
|
1450
|
+
functionName: '',
|
|
1451
|
+
packageName: '',
|
|
1452
|
+
},
|
|
1453
|
+
hasTryCatch: false,
|
|
1454
|
+
hasPromiseCatch: false,
|
|
1455
|
+
checksResponseExists: false,
|
|
1456
|
+
checksStatusCode: false,
|
|
1457
|
+
handledStatusCodes: [],
|
|
1458
|
+
hasRetryLogic: false,
|
|
1459
|
+
};
|
|
1460
|
+
// If instance has global error interceptor, consider it handled
|
|
1461
|
+
if (hasGlobalInterceptor) {
|
|
1462
|
+
analysis.hasTryCatch = true; // Treat global interceptor as equivalent to try-catch
|
|
1463
|
+
}
|
|
1464
|
+
// Check for framework error handler patterns (Priority 2)
|
|
1465
|
+
if (!analysis.hasTryCatch) {
|
|
1466
|
+
// Check if within Express error middleware chain
|
|
1467
|
+
if (this.isWithinExpressErrorMiddleware(node, sourceFile)) {
|
|
1468
|
+
analysis.hasTryCatch = true;
|
|
1469
|
+
}
|
|
1470
|
+
// Check if within NestJS controller with exception filters
|
|
1471
|
+
if (this.isWithinNestJSController(node)) {
|
|
1472
|
+
analysis.hasTryCatch = true;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
// For route handlers (fastify, express), check if the handler callback has try-catch
|
|
1476
|
+
// Pattern: app.get('/route', async (req, res) => { try { ... } catch { ... } })
|
|
1477
|
+
if (!analysis.hasTryCatch) {
|
|
1478
|
+
if (this.isRouteHandlerWithTryCatch(node)) {
|
|
1479
|
+
analysis.hasTryCatch = true;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
// Check if call is inside a try-catch block
|
|
1483
|
+
if (!analysis.hasTryCatch) {
|
|
1484
|
+
analysis.hasTryCatch = this.isInTryCatch(node);
|
|
1485
|
+
}
|
|
1486
|
+
// Check for callback-based error handling (e.g., cloudinary)
|
|
1487
|
+
// Pattern: callback((error, result) => { if (error) return reject(error); })
|
|
1488
|
+
if (!analysis.hasTryCatch) {
|
|
1489
|
+
if (this.hasCallbackErrorHandling(node)) {
|
|
1490
|
+
analysis.hasTryCatch = true;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// Check for resource cleanup patterns (e.g., @vercel/postgres)
|
|
1494
|
+
// Pattern: const client = await pool.connect(); ... finally { client.release(); }
|
|
1495
|
+
if (!analysis.hasTryCatch) {
|
|
1496
|
+
if (this.hasFinallyCleanup(node)) {
|
|
1497
|
+
analysis.hasTryCatch = true;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// Check if there's a .catch() handler
|
|
1501
|
+
const parent = node.parent;
|
|
1502
|
+
if (parent && ts.isPropertyAccessExpression(parent) && parent.name.text === 'catch') {
|
|
1503
|
+
analysis.hasPromiseCatch = true;
|
|
1504
|
+
}
|
|
1505
|
+
// Check if there's a .then(success, error) handler (2-argument form)
|
|
1506
|
+
// Pattern: promise.then(successCallback, errorCallback)
|
|
1507
|
+
if (parent && ts.isPropertyAccessExpression(parent) && parent.name.text === 'then') {
|
|
1508
|
+
// Check if the .then() call has 2 arguments (success and error callbacks)
|
|
1509
|
+
const thenCall = parent.parent;
|
|
1510
|
+
if (thenCall && ts.isCallExpression(thenCall) && thenCall.arguments.length === 2) {
|
|
1511
|
+
analysis.hasPromiseCatch = true;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// Look for error.response checks in surrounding catch blocks
|
|
1515
|
+
const catchClause = this.findEnclosingCatchClause(node);
|
|
1516
|
+
if (catchClause) {
|
|
1517
|
+
analysis.checksResponseExists = this.catchChecksResponseExists(catchClause);
|
|
1518
|
+
analysis.checksStatusCode = this.catchChecksStatusCode(catchClause);
|
|
1519
|
+
analysis.handledStatusCodes = this.extractHandledStatusCodes(catchClause);
|
|
1520
|
+
analysis.hasRetryLogic = this.catchHasRetryLogic(catchClause, sourceFile);
|
|
1521
|
+
}
|
|
1522
|
+
return analysis;
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Checks if a node is inside a try-catch block
|
|
1526
|
+
*/
|
|
1527
|
+
isInTryCatch(node) {
|
|
1528
|
+
let current = node;
|
|
1529
|
+
while (current) {
|
|
1530
|
+
if (ts.isTryStatement(current)) {
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
current = current.parent;
|
|
1534
|
+
}
|
|
1535
|
+
// Check if this is inside a callback/arrow function that contains try-catch
|
|
1536
|
+
// Handles patterns like: app.get('/route', async (req, res) => { try { ... } catch { ... } })
|
|
1537
|
+
if (this.isInsideCallbackWithTryCatch(node)) {
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Checks if a node is inside a callback/arrow function that contains a try-catch block
|
|
1544
|
+
* Handles fastify routes, socket.io event handlers, etc.
|
|
1545
|
+
*/
|
|
1546
|
+
isInsideCallbackWithTryCatch(node) {
|
|
1547
|
+
let current = node;
|
|
1548
|
+
// Walk up to find arrow functions or function expressions
|
|
1549
|
+
while (current) {
|
|
1550
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
|
|
1551
|
+
// Check if this function contains a try-catch
|
|
1552
|
+
if (this.functionContainsTryCatch(current)) {
|
|
1553
|
+
// Make sure the node is actually inside the try block, not just anywhere in the function
|
|
1554
|
+
// We want: async (req, res) => { try { await call() } catch {} }
|
|
1555
|
+
// Not: async (req, res) => { await call(); try { other() } catch {} }
|
|
1556
|
+
return this.isNodeInsideTryBlock(node, current);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
current = current.parent;
|
|
1560
|
+
}
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Checks if a node is inside a try block within a function
|
|
1565
|
+
*/
|
|
1566
|
+
isNodeInsideTryBlock(node, func) {
|
|
1567
|
+
if (!func.body || !ts.isBlock(func.body)) {
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
let nodeIsInside = false;
|
|
1571
|
+
const findTry = (n) => {
|
|
1572
|
+
if (ts.isTryStatement(n)) {
|
|
1573
|
+
// Check if our target node is inside this try block
|
|
1574
|
+
const checkInside = (child) => {
|
|
1575
|
+
if (child === node) {
|
|
1576
|
+
nodeIsInside = true;
|
|
1577
|
+
return true;
|
|
1578
|
+
}
|
|
1579
|
+
let found = false;
|
|
1580
|
+
ts.forEachChild(child, (c) => {
|
|
1581
|
+
if (checkInside(c)) {
|
|
1582
|
+
found = true;
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
return found;
|
|
1586
|
+
};
|
|
1587
|
+
checkInside(n.tryBlock);
|
|
1588
|
+
if (nodeIsInside)
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
ts.forEachChild(n, findTry);
|
|
1592
|
+
};
|
|
1593
|
+
findTry(func.body);
|
|
1594
|
+
return nodeIsInside;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Checks if a function contains a try-catch block in its body
|
|
1598
|
+
*/
|
|
1599
|
+
functionContainsTryCatch(func) {
|
|
1600
|
+
if (!func.body) {
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
// For arrow functions with block bodies and regular functions
|
|
1604
|
+
if (ts.isBlock(func.body)) {
|
|
1605
|
+
let hasTryCatch = false;
|
|
1606
|
+
const visit = (node) => {
|
|
1607
|
+
if (ts.isTryStatement(node)) {
|
|
1608
|
+
hasTryCatch = true;
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
ts.forEachChild(node, visit);
|
|
1612
|
+
};
|
|
1613
|
+
visit(func.body);
|
|
1614
|
+
return hasTryCatch;
|
|
1615
|
+
}
|
|
1616
|
+
return false;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Checks if this is a route/event handler registration with try-catch in the handler
|
|
1620
|
+
* Handles: fastify routes (app.get, app.post), socket.io events (io.on, socket.on)
|
|
1621
|
+
* Pattern: app.get('/route', async (req, res) => { try { ... } catch { ... } })
|
|
1622
|
+
*/
|
|
1623
|
+
isRouteHandlerWithTryCatch(node) {
|
|
1624
|
+
if (!ts.isPropertyAccessExpression(node.expression)) {
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
const methodName = node.expression.name.text;
|
|
1628
|
+
// Check if this looks like a route/event handler registration
|
|
1629
|
+
const isRouteOrEventHandler = ['get', 'post', 'put', 'patch', 'delete', 'all', 'use', 'on'].includes(methodName);
|
|
1630
|
+
if (!isRouteOrEventHandler) {
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
// Find the handler callback (usually last argument, or second for routes with path)
|
|
1634
|
+
// Route: app.get('/path', handler)
|
|
1635
|
+
// Event: io.on('event', handler)
|
|
1636
|
+
for (const arg of node.arguments) {
|
|
1637
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
|
|
1638
|
+
// Check if this handler is async (has async keyword or contains await)
|
|
1639
|
+
const isAsync = !!(arg.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword));
|
|
1640
|
+
if (isAsync || this.functionContainsAwait(arg)) {
|
|
1641
|
+
// For async handlers, check if they have try-catch
|
|
1642
|
+
return this.functionContainsTryCatch(arg);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return false;
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Checks if a function contains await expressions
|
|
1650
|
+
*/
|
|
1651
|
+
functionContainsAwait(func) {
|
|
1652
|
+
if (!func.body) {
|
|
1653
|
+
return false;
|
|
1654
|
+
}
|
|
1655
|
+
let hasAwait = false;
|
|
1656
|
+
const visit = (node) => {
|
|
1657
|
+
if (ts.isAwaitExpression(node)) {
|
|
1658
|
+
hasAwait = true;
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
ts.forEachChild(node, visit);
|
|
1662
|
+
};
|
|
1663
|
+
visit(func.body);
|
|
1664
|
+
return hasAwait;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Checks if there's a finally block that cleans up resources
|
|
1668
|
+
* Pattern: const client = await pool.connect(); ... finally { client.release(); }
|
|
1669
|
+
*/
|
|
1670
|
+
hasFinallyCleanup(node) {
|
|
1671
|
+
// Find the containing function
|
|
1672
|
+
const containingFunction = this.findContainingFunction(node);
|
|
1673
|
+
if (!containingFunction || !containingFunction.body) {
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
// Check if the result of this call is assigned to a variable
|
|
1677
|
+
const parent = node.parent;
|
|
1678
|
+
let variableName;
|
|
1679
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
1680
|
+
variableName = parent.name.text;
|
|
1681
|
+
}
|
|
1682
|
+
else if (parent && ts.isAwaitExpression(parent)) {
|
|
1683
|
+
const awaitParent = parent.parent;
|
|
1684
|
+
if (awaitParent && ts.isVariableDeclaration(awaitParent) && ts.isIdentifier(awaitParent.name)) {
|
|
1685
|
+
variableName = awaitParent.name.text;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (!variableName) {
|
|
1689
|
+
return false;
|
|
1690
|
+
}
|
|
1691
|
+
// Look for a finally block in the function that calls a cleanup method on this variable
|
|
1692
|
+
if (!ts.isBlock(containingFunction.body)) {
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
let hasFinallyWithCleanup = false;
|
|
1696
|
+
const visit = (n) => {
|
|
1697
|
+
if (ts.isTryStatement(n) && n.finallyBlock) {
|
|
1698
|
+
// Check if the finally block calls a cleanup method on our variable
|
|
1699
|
+
// Common patterns: client.release(), connection.close(), stream.end()
|
|
1700
|
+
if (this.finallyBlockCallsCleanup(n.finallyBlock, variableName)) {
|
|
1701
|
+
hasFinallyWithCleanup = true;
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
ts.forEachChild(n, visit);
|
|
1706
|
+
};
|
|
1707
|
+
visit(containingFunction.body);
|
|
1708
|
+
return hasFinallyWithCleanup;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Checks if a finally block calls a cleanup method on a variable
|
|
1712
|
+
*/
|
|
1713
|
+
finallyBlockCallsCleanup(finallyBlock, variableName) {
|
|
1714
|
+
let hasCleanup = false;
|
|
1715
|
+
const visit = (node) => {
|
|
1716
|
+
// Look for: variable.release(), variable.close(), variable.end(), variable.destroy()
|
|
1717
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
1718
|
+
const object = node.expression.expression;
|
|
1719
|
+
const method = node.expression.name.text;
|
|
1720
|
+
if (ts.isIdentifier(object) && object.text === variableName) {
|
|
1721
|
+
const cleanupMethods = ['release', 'close', 'end', 'destroy', 'disconnect', 'dispose'];
|
|
1722
|
+
if (cleanupMethods.includes(method.toLowerCase())) {
|
|
1723
|
+
hasCleanup = true;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
ts.forEachChild(node, visit);
|
|
1729
|
+
};
|
|
1730
|
+
visit(finallyBlock);
|
|
1731
|
+
return hasCleanup;
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Checks if a call uses callback-based error handling
|
|
1735
|
+
* Pattern: callback((error, result) => { if (error) return reject(error); })
|
|
1736
|
+
*/
|
|
1737
|
+
hasCallbackErrorHandling(node) {
|
|
1738
|
+
// Check if any argument is a callback with error parameter
|
|
1739
|
+
for (const arg of node.arguments) {
|
|
1740
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
|
|
1741
|
+
// Check if first parameter is named 'error', 'err', or 'e'
|
|
1742
|
+
if (arg.parameters.length >= 1) {
|
|
1743
|
+
const firstParam = arg.parameters[0];
|
|
1744
|
+
if (ts.isIdentifier(firstParam.name)) {
|
|
1745
|
+
const paramName = firstParam.name.text.toLowerCase();
|
|
1746
|
+
if (paramName === 'error' || paramName === 'err' || paramName === 'e') {
|
|
1747
|
+
// Check if the function body checks this error parameter
|
|
1748
|
+
if (arg.body && this.callbackChecksErrorParam(arg.body, firstParam.name.text)) {
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Checks if a callback function body checks the error parameter
|
|
1760
|
+
* Looks for patterns like: if (error) or if (err)
|
|
1761
|
+
*/
|
|
1762
|
+
callbackChecksErrorParam(body, errorParamName) {
|
|
1763
|
+
let checksError = false;
|
|
1764
|
+
const visit = (node) => {
|
|
1765
|
+
// Look for if statements that check the error parameter
|
|
1766
|
+
if (ts.isIfStatement(node)) {
|
|
1767
|
+
const condition = node.expression;
|
|
1768
|
+
// Direct check: if (error)
|
|
1769
|
+
if (ts.isIdentifier(condition) && condition.text === errorParamName) {
|
|
1770
|
+
checksError = true;
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
// Negated check: if (!error) or if (error == null)
|
|
1774
|
+
if (ts.isPrefixUnaryExpression(condition)) {
|
|
1775
|
+
if (ts.isIdentifier(condition.operand) && condition.operand.text === errorParamName) {
|
|
1776
|
+
checksError = true;
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
// Binary check: if (error !== null) or if (error)
|
|
1781
|
+
if (ts.isBinaryExpression(condition)) {
|
|
1782
|
+
if (ts.isIdentifier(condition.left) && condition.left.text === errorParamName) {
|
|
1783
|
+
checksError = true;
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (ts.isIdentifier(condition.right) && condition.right.text === errorParamName) {
|
|
1787
|
+
checksError = true;
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
ts.forEachChild(node, visit);
|
|
1793
|
+
};
|
|
1794
|
+
if (ts.isBlock(body)) {
|
|
1795
|
+
visit(body);
|
|
1796
|
+
}
|
|
1797
|
+
return checksError;
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Finds the enclosing catch clause for a node
|
|
1801
|
+
*/
|
|
1802
|
+
findEnclosingCatchClause(node) {
|
|
1803
|
+
let current = node;
|
|
1804
|
+
while (current) {
|
|
1805
|
+
if (ts.isTryStatement(current) && current.catchClause) {
|
|
1806
|
+
return current.catchClause;
|
|
1807
|
+
}
|
|
1808
|
+
current = current.parent;
|
|
1809
|
+
}
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Checks if a catch block checks error.response exists
|
|
1814
|
+
*/
|
|
1815
|
+
catchChecksResponseExists(catchClause) {
|
|
1816
|
+
let found = false;
|
|
1817
|
+
const visit = (node) => {
|
|
1818
|
+
// Look for if statements checking error.response
|
|
1819
|
+
if (ts.isIfStatement(node)) {
|
|
1820
|
+
const expression = node.expression;
|
|
1821
|
+
// Check the if condition for error.response patterns
|
|
1822
|
+
const hasResponseCheck = this.expressionChecksResponse(expression);
|
|
1823
|
+
if (hasResponseCheck) {
|
|
1824
|
+
found = true;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
// Look for optional chaining: error.response?.status or error.response?.data
|
|
1828
|
+
if (ts.isPropertyAccessExpression(node) && node.questionDotToken) {
|
|
1829
|
+
if (ts.isPropertyAccessExpression(node.expression) &&
|
|
1830
|
+
node.expression.name.text === 'response') {
|
|
1831
|
+
found = true;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
ts.forEachChild(node, visit);
|
|
1835
|
+
};
|
|
1836
|
+
visit(catchClause.block);
|
|
1837
|
+
return found;
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Checks if an expression checks for response property
|
|
1841
|
+
*/
|
|
1842
|
+
expressionChecksResponse(node) {
|
|
1843
|
+
// Direct check: if (error.response)
|
|
1844
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'response') {
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
// Negated check: if (!error.response)
|
|
1848
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
1849
|
+
if (ts.isPropertyAccessExpression(node.operand) && node.operand.name.text === 'response') {
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
// Binary expression: if (error.response && ...)
|
|
1854
|
+
if (ts.isBinaryExpression(node)) {
|
|
1855
|
+
return this.expressionChecksResponse(node.left) || this.expressionChecksResponse(node.right);
|
|
1856
|
+
}
|
|
1857
|
+
// Parenthesized: if ((error.response))
|
|
1858
|
+
if (ts.isParenthesizedExpression(node)) {
|
|
1859
|
+
return this.expressionChecksResponse(node.expression);
|
|
1860
|
+
}
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Checks if a catch block checks status codes
|
|
1865
|
+
*/
|
|
1866
|
+
catchChecksStatusCode(catchClause) {
|
|
1867
|
+
let found = false;
|
|
1868
|
+
const visit = (node) => {
|
|
1869
|
+
// Look for: error.response.status
|
|
1870
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'status') {
|
|
1871
|
+
const expr = node.expression;
|
|
1872
|
+
if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'response') {
|
|
1873
|
+
found = true;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
ts.forEachChild(node, visit);
|
|
1877
|
+
};
|
|
1878
|
+
visit(catchClause.block);
|
|
1879
|
+
return found;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Extracts which status codes are explicitly handled
|
|
1883
|
+
*/
|
|
1884
|
+
extractHandledStatusCodes(catchClause) {
|
|
1885
|
+
const codes = [];
|
|
1886
|
+
const visit = (node) => {
|
|
1887
|
+
// Look for: error.response.status === 429
|
|
1888
|
+
if (ts.isBinaryExpression(node) &&
|
|
1889
|
+
(node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken ||
|
|
1890
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken)) {
|
|
1891
|
+
if (ts.isNumericLiteral(node.right)) {
|
|
1892
|
+
const statusCode = parseInt(node.right.text, 10);
|
|
1893
|
+
if (statusCode >= 100 && statusCode < 600) {
|
|
1894
|
+
codes.push(statusCode);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
ts.forEachChild(node, visit);
|
|
1899
|
+
};
|
|
1900
|
+
visit(catchClause.block);
|
|
1901
|
+
return codes;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Checks if catch block has retry logic
|
|
1905
|
+
*/
|
|
1906
|
+
catchHasRetryLogic(catchClause, sourceFile) {
|
|
1907
|
+
// Look for common retry patterns: retry, attempt, backoff, setTimeout, etc.
|
|
1908
|
+
const text = catchClause.getText(sourceFile).toLowerCase();
|
|
1909
|
+
return text.includes('retry') ||
|
|
1910
|
+
text.includes('backoff') ||
|
|
1911
|
+
text.includes('attempt') ||
|
|
1912
|
+
(text.includes('settimeout') && text.includes('delay'));
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Checks if a postcondition is violated at a call site
|
|
1916
|
+
*/
|
|
1917
|
+
checkPostcondition(callSite, postcondition, analysis, packageName, functionName, node, sourceFile) {
|
|
1918
|
+
const hasAnyErrorHandling = analysis.hasTryCatch || analysis.hasPromiseCatch;
|
|
1919
|
+
// Clerk-specific: Null check detection for auth(), currentUser(), getToken()
|
|
1920
|
+
// Check this BEFORE generic try-catch check because these functions use null checks, not try-catch
|
|
1921
|
+
if (postcondition.id === 'auth-null-not-checked' ||
|
|
1922
|
+
postcondition.id === 'current-user-null-not-handled' ||
|
|
1923
|
+
postcondition.id === 'get-token-null-not-handled') {
|
|
1924
|
+
const hasNullCheck = this.checkNullHandling(node, sourceFile);
|
|
1925
|
+
if (!hasNullCheck) {
|
|
1926
|
+
const description = postcondition.throws ||
|
|
1927
|
+
`${functionName}() result used without null check - will crash if user not authenticated.`;
|
|
1928
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, description, 'error');
|
|
1929
|
+
}
|
|
1930
|
+
else {
|
|
1931
|
+
// Has null check, so this is handled correctly - don't flag as violation
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
// Clerk-specific: Middleware file system check
|
|
1936
|
+
// Check this BEFORE generic try-catch because it requires file inspection, not try-catch
|
|
1937
|
+
if (postcondition.id === 'middleware-not-exported') {
|
|
1938
|
+
const middlewareExists = this.checkClerkMiddlewareExists();
|
|
1939
|
+
if (!middlewareExists) {
|
|
1940
|
+
const description = postcondition.throws ||
|
|
1941
|
+
'Middleware file not found or clerkMiddleware not properly exported. auth() calls will fail at runtime.';
|
|
1942
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, description, 'error');
|
|
1943
|
+
}
|
|
1944
|
+
else {
|
|
1945
|
+
// Middleware is properly configured - no violation
|
|
1946
|
+
return null;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
// Clerk-specific: Check for middleware matcher configuration
|
|
1950
|
+
if (postcondition.id === 'middleware-matcher-missing') {
|
|
1951
|
+
const middlewarePath = this.checkFileExists('middleware.ts', ['middleware.ts', 'middleware.js']);
|
|
1952
|
+
if (middlewarePath) {
|
|
1953
|
+
// Check if the middleware file exports a config with matcher
|
|
1954
|
+
const sourceFile = this.program.getSourceFile(middlewarePath);
|
|
1955
|
+
let hasMatcherConfig = false;
|
|
1956
|
+
if (sourceFile) {
|
|
1957
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
1958
|
+
// Look for: export const config = { matcher: ... }
|
|
1959
|
+
if (ts.isVariableStatement(node)) {
|
|
1960
|
+
const modifiers = ts.getCombinedModifierFlags(node.declarationList.declarations[0]);
|
|
1961
|
+
if (modifiers & ts.ModifierFlags.Export) {
|
|
1962
|
+
for (const declaration of node.declarationList.declarations) {
|
|
1963
|
+
if (ts.isVariableDeclaration(declaration) &&
|
|
1964
|
+
ts.isIdentifier(declaration.name) &&
|
|
1965
|
+
declaration.name.text === 'config') {
|
|
1966
|
+
hasMatcherConfig = true;
|
|
1967
|
+
break;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
if (!hasMatcherConfig) {
|
|
1974
|
+
const description = postcondition.throws ||
|
|
1975
|
+
'Middleware missing matcher configuration. Will run on all routes including static assets.';
|
|
1976
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, description, 'warning');
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return null;
|
|
1981
|
+
}
|
|
1982
|
+
// Twilio-specific: Hardcoded credentials check
|
|
1983
|
+
if (postcondition.id === 'hardcoded-credentials') {
|
|
1984
|
+
const hasHardcodedCredentials = this.checkHardcodedCredentials(node);
|
|
1985
|
+
if (hasHardcodedCredentials) {
|
|
1986
|
+
const description = postcondition.throws ||
|
|
1987
|
+
'Hardcoded credentials detected. Use environment variables (process.env) to avoid security risks.';
|
|
1988
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, description, 'error');
|
|
1989
|
+
}
|
|
1990
|
+
else {
|
|
1991
|
+
// Credentials are from environment variables - no violation
|
|
1992
|
+
return null;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
// NEW: Generic check for any postcondition requiring error handling
|
|
1996
|
+
// If the postcondition specifies required_handling and has severity='error',
|
|
1997
|
+
// it means the call MUST have error handling
|
|
1998
|
+
if (postcondition.required_handling && postcondition.severity === 'error') {
|
|
1999
|
+
if (!hasAnyErrorHandling) {
|
|
2000
|
+
// Generate a violation with a generic message based on the postcondition.condition
|
|
2001
|
+
const description = postcondition.throws
|
|
2002
|
+
? `No try-catch block found. ${postcondition.throws} - this will crash the application.`
|
|
2003
|
+
: 'No error handling found. This operation can throw errors that will crash the application.';
|
|
2004
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, description, 'error');
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
// Specific violation checks based on postcondition ID (for more detailed analysis)
|
|
2008
|
+
if (postcondition.id.includes('429') || postcondition.id.includes('rate-limit')) {
|
|
2009
|
+
// Rate limiting check
|
|
2010
|
+
if (!hasAnyErrorHandling) {
|
|
2011
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'No try-catch block found. Rate limit errors (429) will crash the application.', 'error');
|
|
2012
|
+
}
|
|
2013
|
+
// WARNING: Has error handling but doesn't handle 429 specifically
|
|
2014
|
+
if (!analysis.handledStatusCodes.includes(429) && !analysis.hasRetryLogic) {
|
|
2015
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'Rate limit response (429) is not explicitly handled. Consider implementing retry logic with exponential backoff.', 'warning');
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
// HTTP client packages (axios, node-fetch, etc.) - check for HTTP-specific error handling
|
|
2019
|
+
const isHttpClient = ['axios', 'node-fetch', 'got', 'superagent', 'request'].includes(packageName);
|
|
2020
|
+
if (postcondition.id.includes('network')) {
|
|
2021
|
+
// Network failure check
|
|
2022
|
+
if (!hasAnyErrorHandling) {
|
|
2023
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'No try-catch block found. Network failures will crash the application.', 'error');
|
|
2024
|
+
}
|
|
2025
|
+
// WARNING: Only for HTTP clients - check if response.exists is checked
|
|
2026
|
+
if (isHttpClient && hasAnyErrorHandling && !analysis.checksResponseExists) {
|
|
2027
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'Generic error handling found. Consider checking if error.response exists to distinguish network failures from HTTP errors.', 'warning');
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
if (postcondition.id.includes('error') && postcondition.severity === 'error') {
|
|
2031
|
+
// Generic error handling check
|
|
2032
|
+
if (!hasAnyErrorHandling) {
|
|
2033
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'No error handling found. Errors will crash the application.', 'error');
|
|
2034
|
+
}
|
|
2035
|
+
// WARNING: Only for HTTP clients - check if status codes are inspected
|
|
2036
|
+
if (isHttpClient && hasAnyErrorHandling && !analysis.checksStatusCode) {
|
|
2037
|
+
return this.createViolation(callSite, postcondition, packageName, functionName, 'Generic error handling found. Consider inspecting error.response.status to distinguish between 4xx client errors and 5xx server errors for better UX.', 'warning');
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Checks if a function call result has proper null handling
|
|
2044
|
+
* Used for Clerk functions that return null when not authenticated
|
|
2045
|
+
*/
|
|
2046
|
+
checkNullHandling(callNode, sourceFile) {
|
|
2047
|
+
// Find the parent statement containing this call
|
|
2048
|
+
let currentNode = callNode;
|
|
2049
|
+
while (currentNode && !ts.isStatement(currentNode)) {
|
|
2050
|
+
currentNode = currentNode.parent;
|
|
2051
|
+
}
|
|
2052
|
+
if (!currentNode)
|
|
2053
|
+
return false;
|
|
2054
|
+
// Find the containing function/method
|
|
2055
|
+
const containingFunction = this.findContainingFunction(callNode);
|
|
2056
|
+
if (!containingFunction)
|
|
2057
|
+
return false;
|
|
2058
|
+
// Look for variable declaration or destructuring
|
|
2059
|
+
let variableNames = [];
|
|
2060
|
+
// Check if the call is assigned to a variable
|
|
2061
|
+
const parent = callNode.parent;
|
|
2062
|
+
// Case 1: await auth() directly in variable declaration
|
|
2063
|
+
if (ts.isAwaitExpression(parent)) {
|
|
2064
|
+
const awaitParent = parent.parent;
|
|
2065
|
+
if (ts.isVariableDeclaration(awaitParent) && awaitParent.name) {
|
|
2066
|
+
if (ts.isIdentifier(awaitParent.name)) {
|
|
2067
|
+
variableNames.push(awaitParent.name.text);
|
|
2068
|
+
}
|
|
2069
|
+
else if (ts.isObjectBindingPattern(awaitParent.name)) {
|
|
2070
|
+
// Destructured: const { userId } = await auth()
|
|
2071
|
+
awaitParent.name.elements.forEach(element => {
|
|
2072
|
+
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
|
|
2073
|
+
variableNames.push(element.name.text);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
// Case 2: Direct variable declaration
|
|
2080
|
+
if (ts.isVariableDeclaration(parent) && parent.name) {
|
|
2081
|
+
if (ts.isIdentifier(parent.name)) {
|
|
2082
|
+
variableNames.push(parent.name.text);
|
|
2083
|
+
}
|
|
2084
|
+
else if (ts.isObjectBindingPattern(parent.name)) {
|
|
2085
|
+
parent.name.elements.forEach(element => {
|
|
2086
|
+
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
|
|
2087
|
+
variableNames.push(element.name.text);
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
if (variableNames.length === 0) {
|
|
2093
|
+
// No variable captured, assume used directly (would be flagged)
|
|
2094
|
+
return false;
|
|
2095
|
+
}
|
|
2096
|
+
// Now check if any of these variables are null-checked before use
|
|
2097
|
+
let hasNullCheck = false;
|
|
2098
|
+
const checkForNullHandling = (node) => {
|
|
2099
|
+
// Check for if statements with null checks
|
|
2100
|
+
if (ts.isIfStatement(node)) {
|
|
2101
|
+
const condition = node.expression;
|
|
2102
|
+
if (this.isNullCheckCondition(condition, variableNames, sourceFile)) {
|
|
2103
|
+
hasNullCheck = true;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
// Check for optional chaining on the variable
|
|
2107
|
+
if (ts.isPropertyAccessExpression(node) && node.questionDotToken) {
|
|
2108
|
+
const exprText = node.expression.getText(sourceFile);
|
|
2109
|
+
if (variableNames.includes(exprText)) {
|
|
2110
|
+
hasNullCheck = true;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
// Check for early return with null check
|
|
2114
|
+
if (ts.isReturnStatement(node) || ts.isExpressionStatement(node)) {
|
|
2115
|
+
const parent = node.parent;
|
|
2116
|
+
if (parent && ts.isIfStatement(parent)) {
|
|
2117
|
+
if (this.isNullCheckCondition(parent.expression, variableNames, sourceFile)) {
|
|
2118
|
+
hasNullCheck = true;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
ts.forEachChild(node, checkForNullHandling);
|
|
2123
|
+
};
|
|
2124
|
+
ts.forEachChild(containingFunction, checkForNullHandling);
|
|
2125
|
+
return hasNullCheck;
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Checks if a condition is a null check for the given variables
|
|
2129
|
+
*/
|
|
2130
|
+
isNullCheckCondition(condition, variableNames, sourceFile) {
|
|
2131
|
+
const conditionText = condition.getText(sourceFile);
|
|
2132
|
+
// Check if any of our variables are mentioned in the condition
|
|
2133
|
+
const mentionsVariable = variableNames.some(varName => conditionText.includes(varName));
|
|
2134
|
+
if (!mentionsVariable)
|
|
2135
|
+
return false;
|
|
2136
|
+
// Pattern: !variable or !userId
|
|
2137
|
+
if (ts.isPrefixUnaryExpression(condition) && condition.operator === ts.SyntaxKind.ExclamationToken) {
|
|
2138
|
+
const operandText = condition.operand.getText(sourceFile);
|
|
2139
|
+
return variableNames.includes(operandText);
|
|
2140
|
+
}
|
|
2141
|
+
// Pattern: variable === null, variable !== null, etc.
|
|
2142
|
+
if (ts.isBinaryExpression(condition)) {
|
|
2143
|
+
const operator = condition.operatorToken.kind;
|
|
2144
|
+
// Handle || and && by recursively checking both sides
|
|
2145
|
+
if (operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
2146
|
+
return this.isNullCheckCondition(condition.left, variableNames, sourceFile) ||
|
|
2147
|
+
this.isNullCheckCondition(condition.right, variableNames, sourceFile);
|
|
2148
|
+
}
|
|
2149
|
+
const leftText = condition.left.getText(sourceFile);
|
|
2150
|
+
const rightText = condition.right.getText(sourceFile);
|
|
2151
|
+
const hasVariable = variableNames.some(v => leftText.includes(v) || rightText.includes(v));
|
|
2152
|
+
const hasNullCheck = conditionText.includes('null') || conditionText.includes('undefined');
|
|
2153
|
+
const isComparisonOperator = operator === ts.SyntaxKind.EqualsEqualsToken ||
|
|
2154
|
+
operator === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
2155
|
+
operator === ts.SyntaxKind.ExclamationEqualsToken ||
|
|
2156
|
+
operator === ts.SyntaxKind.ExclamationEqualsEqualsToken;
|
|
2157
|
+
if (hasVariable && hasNullCheck && isComparisonOperator) {
|
|
2158
|
+
return true;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
// Pattern: isAuthenticated or similar boolean check
|
|
2162
|
+
if (ts.isIdentifier(condition)) {
|
|
2163
|
+
return variableNames.includes(condition.text);
|
|
2164
|
+
}
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Finds the containing function/method for a node
|
|
2169
|
+
*/
|
|
2170
|
+
findContainingFunction(node) {
|
|
2171
|
+
let current = node.parent;
|
|
2172
|
+
while (current) {
|
|
2173
|
+
if (ts.isFunctionDeclaration(current) ||
|
|
2174
|
+
ts.isArrowFunction(current) ||
|
|
2175
|
+
ts.isFunctionExpression(current) ||
|
|
2176
|
+
ts.isMethodDeclaration(current)) {
|
|
2177
|
+
return current;
|
|
2178
|
+
}
|
|
2179
|
+
current = current.parent;
|
|
2180
|
+
}
|
|
2181
|
+
return null;
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Checks if a function call has hardcoded credentials (string literals)
|
|
2185
|
+
* vs environment variables (process.env.*)
|
|
2186
|
+
*
|
|
2187
|
+
* Returns true if hardcoded credentials are detected (violation)
|
|
2188
|
+
* Returns false if credentials come from environment variables (valid)
|
|
2189
|
+
*/
|
|
2190
|
+
checkHardcodedCredentials(callNode) {
|
|
2191
|
+
// Check each argument to the function call
|
|
2192
|
+
for (const arg of callNode.arguments) {
|
|
2193
|
+
// Check if argument is a string literal (hardcoded)
|
|
2194
|
+
if (ts.isStringLiteral(arg)) {
|
|
2195
|
+
// String literal = hardcoded credential = violation
|
|
2196
|
+
return true;
|
|
2197
|
+
}
|
|
2198
|
+
// Check if argument is a template expression (could be hardcoded)
|
|
2199
|
+
if (ts.isTemplateExpression(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
2200
|
+
// Template literal without substitutions = hardcoded = violation
|
|
2201
|
+
if (ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
2202
|
+
return true;
|
|
2203
|
+
}
|
|
2204
|
+
// Template with substitutions = could be dynamic, check the spans
|
|
2205
|
+
// For safety, we'll flag any template literal as hardcoded
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
// Check if argument is an identifier (variable)
|
|
2209
|
+
if (ts.isIdentifier(arg)) {
|
|
2210
|
+
// Need to trace back to see where this variable is defined
|
|
2211
|
+
// If it's from process.env, it's safe
|
|
2212
|
+
// For now, we'll trace variable declarations in the current scope
|
|
2213
|
+
const varDeclaration = this.findVariableDeclaration(arg.text, callNode);
|
|
2214
|
+
if (varDeclaration && varDeclaration.initializer) {
|
|
2215
|
+
// Check if initializer is process.env.*
|
|
2216
|
+
if (ts.isPropertyAccessExpression(varDeclaration.initializer)) {
|
|
2217
|
+
const expr = varDeclaration.initializer.expression;
|
|
2218
|
+
if (ts.isPropertyAccessExpression(expr) &&
|
|
2219
|
+
ts.isIdentifier(expr.expression) &&
|
|
2220
|
+
expr.expression.text === 'process' &&
|
|
2221
|
+
ts.isIdentifier(expr.name) &&
|
|
2222
|
+
expr.name.text === 'env') {
|
|
2223
|
+
// This is process.env.SOMETHING - safe!
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
// Check if initializer is a string literal
|
|
2228
|
+
if (ts.isStringLiteral(varDeclaration.initializer)) {
|
|
2229
|
+
// Variable assigned from string literal = hardcoded
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
// Check if argument is process.env.* directly
|
|
2235
|
+
if (ts.isPropertyAccessExpression(arg)) {
|
|
2236
|
+
const expr = arg.expression;
|
|
2237
|
+
if (ts.isPropertyAccessExpression(expr) &&
|
|
2238
|
+
ts.isIdentifier(expr.expression) &&
|
|
2239
|
+
expr.expression.text === 'process' &&
|
|
2240
|
+
ts.isIdentifier(expr.name) &&
|
|
2241
|
+
expr.name.text === 'env') {
|
|
2242
|
+
// Direct process.env.* usage - safe!
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
// Some other property access - could be config.apiKey, etc.
|
|
2246
|
+
// For safety, we'll flag it as potentially hardcoded
|
|
2247
|
+
// TODO: Could enhance to trace config objects
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2250
|
+
// Check for element access: process.env['VARIABLE']
|
|
2251
|
+
if (ts.isElementAccessExpression(arg)) {
|
|
2252
|
+
const expr = arg.expression;
|
|
2253
|
+
if (ts.isPropertyAccessExpression(expr) &&
|
|
2254
|
+
ts.isIdentifier(expr.expression) &&
|
|
2255
|
+
expr.expression.text === 'process' &&
|
|
2256
|
+
ts.isIdentifier(expr.name) &&
|
|
2257
|
+
expr.name.text === 'env') {
|
|
2258
|
+
// process.env['VARIABLE'] - safe!
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
// Some other element access - potentially hardcoded
|
|
2262
|
+
return true;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
// All arguments checked, no hardcoded credentials found
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Finds a variable declaration in the scope of the given node
|
|
2270
|
+
*/
|
|
2271
|
+
findVariableDeclaration(variableName, node) {
|
|
2272
|
+
let current = node;
|
|
2273
|
+
while (current) {
|
|
2274
|
+
// Check variable statements in this scope
|
|
2275
|
+
if (ts.isSourceFile(current) || ts.isBlock(current) || ts.isFunctionLike(current)) {
|
|
2276
|
+
let foundDeclaration = null;
|
|
2277
|
+
const visitNode = (node) => {
|
|
2278
|
+
if (foundDeclaration)
|
|
2279
|
+
return;
|
|
2280
|
+
if (ts.isVariableDeclaration(node) &&
|
|
2281
|
+
ts.isIdentifier(node.name) &&
|
|
2282
|
+
node.name.text === variableName) {
|
|
2283
|
+
foundDeclaration = node;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
// Don't recurse into nested functions/blocks
|
|
2287
|
+
if (node === current || ts.isVariableStatement(node) || ts.isVariableDeclarationList(node)) {
|
|
2288
|
+
ts.forEachChild(node, visitNode);
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
visitNode(current);
|
|
2292
|
+
if (foundDeclaration) {
|
|
2293
|
+
return foundDeclaration;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
current = current.parent;
|
|
2297
|
+
}
|
|
2298
|
+
return null;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Checks if a specific file exists in the project
|
|
2302
|
+
* Tries multiple possible locations (root, src/, etc.)
|
|
2303
|
+
*
|
|
2304
|
+
* @param fileName - The file name to search for (e.g., 'middleware.ts')
|
|
2305
|
+
* @param variations - Optional variations of the file name (e.g., ['middleware.ts', 'middleware.js'])
|
|
2306
|
+
* @returns The full path if found, null otherwise
|
|
2307
|
+
*/
|
|
2308
|
+
checkFileExists(fileName, variations) {
|
|
2309
|
+
const filesToCheck = variations || [fileName];
|
|
2310
|
+
const locationsToCheck = [
|
|
2311
|
+
'', // Root directory
|
|
2312
|
+
'src', // src/ directory
|
|
2313
|
+
'app', // app/ directory (Next.js App Router)
|
|
2314
|
+
];
|
|
2315
|
+
for (const location of locationsToCheck) {
|
|
2316
|
+
for (const file of filesToCheck) {
|
|
2317
|
+
const fullPath = path.join(this.projectRoot, location, file);
|
|
2318
|
+
if (ts.sys.fileExists(fullPath)) {
|
|
2319
|
+
return fullPath;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Checks if a file imports and exports specific patterns
|
|
2327
|
+
*
|
|
2328
|
+
* @param filePath - Absolute path to the file to check
|
|
2329
|
+
* @param importPattern - Object specifying what to look for in imports
|
|
2330
|
+
* @param exportPattern - Object specifying what to look for in exports
|
|
2331
|
+
* @returns Object with hasImport and hasExport booleans
|
|
2332
|
+
*/
|
|
2333
|
+
checkFileImportsAndExports(filePath, importPattern, exportPattern) {
|
|
2334
|
+
const sourceFile = this.program.getSourceFile(filePath);
|
|
2335
|
+
if (!sourceFile) {
|
|
2336
|
+
return { hasImport: false, hasExport: false };
|
|
2337
|
+
}
|
|
2338
|
+
let hasImport = false;
|
|
2339
|
+
let hasExport = false;
|
|
2340
|
+
let importedName = null;
|
|
2341
|
+
// Check imports
|
|
2342
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
2343
|
+
if (ts.isImportDeclaration(node)) {
|
|
2344
|
+
const moduleSpecifier = node.moduleSpecifier;
|
|
2345
|
+
if (ts.isStringLiteral(moduleSpecifier)) {
|
|
2346
|
+
const importPath = moduleSpecifier.text;
|
|
2347
|
+
// Check if this import matches the package pattern
|
|
2348
|
+
if (importPath.includes(importPattern.packageName)) {
|
|
2349
|
+
// If specific import name is required, check for it
|
|
2350
|
+
if (importPattern.importName) {
|
|
2351
|
+
if (node.importClause?.namedBindings &&
|
|
2352
|
+
ts.isNamedImports(node.importClause.namedBindings)) {
|
|
2353
|
+
for (const element of node.importClause.namedBindings.elements) {
|
|
2354
|
+
if (element.name.text === importPattern.importName) {
|
|
2355
|
+
hasImport = true;
|
|
2356
|
+
importedName = importPattern.importName;
|
|
2357
|
+
break;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
// No specific import name required, just check package
|
|
2364
|
+
hasImport = true;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
// Check exports
|
|
2370
|
+
if (exportPattern.type === 'default' && ts.isExportAssignment(node)) {
|
|
2371
|
+
// export default ...
|
|
2372
|
+
if (node.expression) {
|
|
2373
|
+
// Check if it's a call expression: export default clerkMiddleware()
|
|
2374
|
+
if (ts.isCallExpression(node.expression)) {
|
|
2375
|
+
const expr = node.expression.expression;
|
|
2376
|
+
if (ts.isIdentifier(expr)) {
|
|
2377
|
+
if (importedName && expr.text === importedName) {
|
|
2378
|
+
hasExport = true;
|
|
2379
|
+
}
|
|
2380
|
+
else if (!importPattern.importName) {
|
|
2381
|
+
// If no specific import name, just check if it's exported
|
|
2382
|
+
hasExport = true;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
if (exportPattern.type === 'named' && ts.isExportDeclaration(node)) {
|
|
2389
|
+
// export { ... }
|
|
2390
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
2391
|
+
for (const element of node.exportClause.elements) {
|
|
2392
|
+
if (exportPattern.exportName && element.name.text === exportPattern.exportName) {
|
|
2393
|
+
hasExport = true;
|
|
2394
|
+
break;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
});
|
|
2400
|
+
return { hasImport, hasExport };
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Checks if middleware.ts exists and properly exports clerkMiddleware
|
|
2404
|
+
* This is specific to @clerk/nextjs middleware setup
|
|
2405
|
+
*
|
|
2406
|
+
* @returns true if middleware is properly configured, false otherwise
|
|
2407
|
+
*/
|
|
2408
|
+
checkClerkMiddlewareExists() {
|
|
2409
|
+
// Check if middleware.ts or middleware.js exists
|
|
2410
|
+
const middlewarePath = this.checkFileExists('middleware.ts', [
|
|
2411
|
+
'middleware.ts',
|
|
2412
|
+
'middleware.js'
|
|
2413
|
+
]);
|
|
2414
|
+
if (!middlewarePath) {
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
// Check if the middleware file imports and exports clerkMiddleware
|
|
2418
|
+
const { hasImport, hasExport } = this.checkFileImportsAndExports(middlewarePath, { packageName: '@clerk/nextjs', importName: 'clerkMiddleware' }, { type: 'default' });
|
|
2419
|
+
return hasImport && hasExport;
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Creates a violation object
|
|
2423
|
+
*/
|
|
2424
|
+
createViolation(callSite, postcondition, packageName, functionName, description, severityOverride) {
|
|
2425
|
+
return {
|
|
2426
|
+
id: `${packageName}-${postcondition.id}`,
|
|
2427
|
+
severity: severityOverride || postcondition.severity,
|
|
2428
|
+
file: callSite.file,
|
|
2429
|
+
line: callSite.line,
|
|
2430
|
+
column: callSite.column,
|
|
2431
|
+
package: packageName,
|
|
2432
|
+
function: functionName,
|
|
2433
|
+
contract_clause: postcondition.id,
|
|
2434
|
+
description,
|
|
2435
|
+
source_doc: postcondition.source,
|
|
2436
|
+
suggested_fix: postcondition.required_handling,
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Gets statistics about the analysis run
|
|
2441
|
+
*/
|
|
2442
|
+
getStats() {
|
|
2443
|
+
return {
|
|
2444
|
+
filesAnalyzed: this.program.getSourceFiles().filter(sf => !sf.isDeclarationFile && !sf.fileName.includes('node_modules')).length,
|
|
2445
|
+
contractsApplied: Array.from(this.contracts.values()).reduce((sum, contract) => sum + (contract.functions?.length || 0), 0),
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Analyzes S3 send() calls to detect command type
|
|
2450
|
+
* Pattern: s3Client.send(new GetObjectCommand(...))
|
|
2451
|
+
*/
|
|
2452
|
+
analyzeS3SendCall(node, sourceFile, s3ClientInstances) {
|
|
2453
|
+
// Check if this is a .send() call
|
|
2454
|
+
if (!ts.isPropertyAccessExpression(node.expression))
|
|
2455
|
+
return null;
|
|
2456
|
+
if (node.expression.name.text !== 'send')
|
|
2457
|
+
return null;
|
|
2458
|
+
// Get the object being called (should be s3Client)
|
|
2459
|
+
let clientIdentifier = null;
|
|
2460
|
+
if (ts.isIdentifier(node.expression.expression)) {
|
|
2461
|
+
clientIdentifier = node.expression.expression.text;
|
|
2462
|
+
}
|
|
2463
|
+
else if (ts.isPropertyAccessExpression(node.expression.expression)) {
|
|
2464
|
+
// Handle this.s3Client.send() pattern
|
|
2465
|
+
clientIdentifier = node.expression.expression.name.text;
|
|
2466
|
+
}
|
|
2467
|
+
if (!clientIdentifier)
|
|
2468
|
+
return null;
|
|
2469
|
+
// Check if this identifier is a tracked S3Client instance
|
|
2470
|
+
const packageName = s3ClientInstances.get(clientIdentifier) ||
|
|
2471
|
+
this.resolvePackageFromImports(clientIdentifier, sourceFile);
|
|
2472
|
+
if (packageName !== '@aws-sdk/client-s3')
|
|
2473
|
+
return null;
|
|
2474
|
+
// Extract the command type from the argument
|
|
2475
|
+
// Pattern: send(new GetObjectCommand(...))
|
|
2476
|
+
if (node.arguments.length === 0)
|
|
2477
|
+
return null;
|
|
2478
|
+
const firstArg = node.arguments[0];
|
|
2479
|
+
if (!ts.isNewExpression(firstArg))
|
|
2480
|
+
return null;
|
|
2481
|
+
const commandName = firstArg.expression.getText(sourceFile);
|
|
2482
|
+
return {
|
|
2483
|
+
client: clientIdentifier,
|
|
2484
|
+
command: commandName,
|
|
2485
|
+
packageName: '@aws-sdk/client-s3'
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
2489
|
+
* Analyzes an S3 command call and creates violations if needed
|
|
2490
|
+
*/
|
|
2491
|
+
analyzeS3Command(s3Analysis, node, sourceFile) {
|
|
2492
|
+
const contract = this.contracts.get(s3Analysis.packageName);
|
|
2493
|
+
if (!contract)
|
|
2494
|
+
return;
|
|
2495
|
+
// Find the send() function contract
|
|
2496
|
+
const sendContract = contract.functions.find(f => f.name === 'send');
|
|
2497
|
+
if (!sendContract)
|
|
2498
|
+
return;
|
|
2499
|
+
// Map command type to postcondition ID
|
|
2500
|
+
const commandToPostcondition = this.mapS3CommandToPostcondition(s3Analysis.command);
|
|
2501
|
+
if (!commandToPostcondition)
|
|
2502
|
+
return;
|
|
2503
|
+
// Find the matching postcondition
|
|
2504
|
+
const postcondition = sendContract.postconditions?.find(p => p.id === commandToPostcondition);
|
|
2505
|
+
if (!postcondition)
|
|
2506
|
+
return;
|
|
2507
|
+
// Check if this is wrapped in try-catch
|
|
2508
|
+
const asyncErrorAnalyzer = new AsyncErrorAnalyzer(sourceFile);
|
|
2509
|
+
// Find the await expression that wraps this call
|
|
2510
|
+
let awaitNode = null;
|
|
2511
|
+
let current = node;
|
|
2512
|
+
while (current) {
|
|
2513
|
+
if (ts.isAwaitExpression(current) && current.expression === node) {
|
|
2514
|
+
awaitNode = current;
|
|
2515
|
+
break;
|
|
2516
|
+
}
|
|
2517
|
+
current = current.parent;
|
|
2518
|
+
}
|
|
2519
|
+
// Check if await is protected by try-catch
|
|
2520
|
+
let isProtected = false;
|
|
2521
|
+
if (awaitNode) {
|
|
2522
|
+
const functionNode = this.findContainingFunction(awaitNode);
|
|
2523
|
+
if (functionNode) {
|
|
2524
|
+
isProtected = asyncErrorAnalyzer.isAwaitProtected(awaitNode, functionNode);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
// Create violation if not protected (for error severity postconditions)
|
|
2528
|
+
if (!isProtected && postcondition.severity === 'error') {
|
|
2529
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
2530
|
+
const violation = {
|
|
2531
|
+
id: `${s3Analysis.packageName}-${postcondition.id}`,
|
|
2532
|
+
severity: postcondition.severity,
|
|
2533
|
+
file: sourceFile.fileName,
|
|
2534
|
+
line: line + 1,
|
|
2535
|
+
column: character + 1,
|
|
2536
|
+
package: s3Analysis.packageName,
|
|
2537
|
+
function: 'send',
|
|
2538
|
+
contract_clause: postcondition.id,
|
|
2539
|
+
description: `${s3Analysis.command} called without try-catch. ${postcondition.condition}`,
|
|
2540
|
+
source_doc: postcondition.source || '',
|
|
2541
|
+
suggested_fix: postcondition.required_handling || '',
|
|
2542
|
+
};
|
|
2543
|
+
this.violations.push(violation);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Maps S3 command types to their corresponding postcondition IDs
|
|
2548
|
+
*/
|
|
2549
|
+
mapS3CommandToPostcondition(commandName) {
|
|
2550
|
+
// Object operations
|
|
2551
|
+
const objectOps = [
|
|
2552
|
+
'GetObjectCommand',
|
|
2553
|
+
'PutObjectCommand',
|
|
2554
|
+
'DeleteObjectCommand',
|
|
2555
|
+
'HeadObjectCommand',
|
|
2556
|
+
'CopyObjectCommand'
|
|
2557
|
+
];
|
|
2558
|
+
if (objectOps.includes(commandName)) {
|
|
2559
|
+
return 's3-object-operation-no-try-catch';
|
|
2560
|
+
}
|
|
2561
|
+
// Multipart operations
|
|
2562
|
+
const multipartOps = [
|
|
2563
|
+
'CreateMultipartUploadCommand',
|
|
2564
|
+
'UploadPartCommand',
|
|
2565
|
+
'CompleteMultipartUploadCommand',
|
|
2566
|
+
'AbortMultipartUploadCommand'
|
|
2567
|
+
];
|
|
2568
|
+
if (multipartOps.includes(commandName)) {
|
|
2569
|
+
return 's3-multipart-no-try-catch';
|
|
2570
|
+
}
|
|
2571
|
+
// Bucket operations
|
|
2572
|
+
const bucketOps = [
|
|
2573
|
+
'CreateBucketCommand',
|
|
2574
|
+
'DeleteBucketCommand',
|
|
2575
|
+
'HeadBucketCommand'
|
|
2576
|
+
];
|
|
2577
|
+
if (bucketOps.includes(commandName)) {
|
|
2578
|
+
return 's3-bucket-operation-no-try-catch';
|
|
2579
|
+
}
|
|
2580
|
+
// List operations
|
|
2581
|
+
const listOps = [
|
|
2582
|
+
'ListObjectsV2Command',
|
|
2583
|
+
'ListObjectsCommand',
|
|
2584
|
+
'ListBucketsCommand'
|
|
2585
|
+
];
|
|
2586
|
+
if (listOps.includes(commandName)) {
|
|
2587
|
+
return 's3-list-operation-no-try-catch';
|
|
2588
|
+
}
|
|
2589
|
+
return null;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
//# sourceMappingURL=analyzer.js.map
|