@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.
Files changed (80) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +694 -0
  3. package/dist/analyze-results.js +253 -0
  4. package/dist/analyzer.d.ts +366 -0
  5. package/dist/analyzer.d.ts.map +1 -0
  6. package/dist/analyzer.js +2592 -0
  7. package/dist/analyzer.js.map +1 -0
  8. package/dist/analyzers/async-error-analyzer.d.ts +72 -0
  9. package/dist/analyzers/async-error-analyzer.d.ts.map +1 -0
  10. package/dist/analyzers/async-error-analyzer.js +243 -0
  11. package/dist/analyzers/async-error-analyzer.js.map +1 -0
  12. package/dist/analyzers/event-listener-analyzer.d.ts +102 -0
  13. package/dist/analyzers/event-listener-analyzer.d.ts.map +1 -0
  14. package/dist/analyzers/event-listener-analyzer.js +253 -0
  15. package/dist/analyzers/event-listener-analyzer.js.map +1 -0
  16. package/dist/analyzers/react-query-analyzer.d.ts +66 -0
  17. package/dist/analyzers/react-query-analyzer.d.ts.map +1 -0
  18. package/dist/analyzers/react-query-analyzer.js +341 -0
  19. package/dist/analyzers/react-query-analyzer.js.map +1 -0
  20. package/dist/analyzers/return-value-analyzer.d.ts +61 -0
  21. package/dist/analyzers/return-value-analyzer.d.ts.map +1 -0
  22. package/dist/analyzers/return-value-analyzer.js +225 -0
  23. package/dist/analyzers/return-value-analyzer.js.map +1 -0
  24. package/dist/code-snippet.d.ts +48 -0
  25. package/dist/code-snippet.d.ts.map +1 -0
  26. package/dist/code-snippet.js +84 -0
  27. package/dist/code-snippet.js.map +1 -0
  28. package/dist/corpus-loader.d.ts +33 -0
  29. package/dist/corpus-loader.d.ts.map +1 -0
  30. package/dist/corpus-loader.js +155 -0
  31. package/dist/corpus-loader.js.map +1 -0
  32. package/dist/fixture-tester.d.ts +28 -0
  33. package/dist/fixture-tester.d.ts.map +1 -0
  34. package/dist/fixture-tester.js +176 -0
  35. package/dist/fixture-tester.js.map +1 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +375 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/package-discovery.d.ts +62 -0
  41. package/dist/package-discovery.d.ts.map +1 -0
  42. package/dist/package-discovery.js +299 -0
  43. package/dist/package-discovery.js.map +1 -0
  44. package/dist/reporter.d.ts +43 -0
  45. package/dist/reporter.d.ts.map +1 -0
  46. package/dist/reporter.js +347 -0
  47. package/dist/reporter.js.map +1 -0
  48. package/dist/reporters/benchmarking.d.ts +70 -0
  49. package/dist/reporters/benchmarking.d.ts.map +1 -0
  50. package/dist/reporters/benchmarking.js +191 -0
  51. package/dist/reporters/benchmarking.js.map +1 -0
  52. package/dist/reporters/d3-visualizer.d.ts +40 -0
  53. package/dist/reporters/d3-visualizer.d.ts.map +1 -0
  54. package/dist/reporters/d3-visualizer.js +803 -0
  55. package/dist/reporters/d3-visualizer.js.map +1 -0
  56. package/dist/reporters/health-score.d.ts +33 -0
  57. package/dist/reporters/health-score.d.ts.map +1 -0
  58. package/dist/reporters/health-score.js +149 -0
  59. package/dist/reporters/health-score.js.map +1 -0
  60. package/dist/reporters/index.d.ts +11 -0
  61. package/dist/reporters/index.d.ts.map +1 -0
  62. package/dist/reporters/index.js +11 -0
  63. package/dist/reporters/index.js.map +1 -0
  64. package/dist/reporters/package-breakdown.d.ts +48 -0
  65. package/dist/reporters/package-breakdown.d.ts.map +1 -0
  66. package/dist/reporters/package-breakdown.js +185 -0
  67. package/dist/reporters/package-breakdown.js.map +1 -0
  68. package/dist/reporters/positive-evidence.d.ts +42 -0
  69. package/dist/reporters/positive-evidence.d.ts.map +1 -0
  70. package/dist/reporters/positive-evidence.js +436 -0
  71. package/dist/reporters/positive-evidence.js.map +1 -0
  72. package/dist/tsconfig-generator.d.ts +17 -0
  73. package/dist/tsconfig-generator.d.ts.map +1 -0
  74. package/dist/tsconfig-generator.js +107 -0
  75. package/dist/tsconfig-generator.js.map +1 -0
  76. package/dist/types.d.ts +298 -0
  77. package/dist/types.d.ts.map +1 -0
  78. package/dist/types.js +5 -0
  79. package/dist/types.js.map +1 -0
  80. package/package.json +59 -0
@@ -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