import { parse, TSESTree } from '@typescript-eslint/typescript-estree'; import { Severity, IssueType } from '@aiready/core'; import type { ContractEnforcementIssue, DetectionResult, DefensivePattern, PatternCounts, } from './types'; import { ZERO_COUNTS } from './types'; function makeIssue( pattern: DefensivePattern, severity: Severity, message: string, filePath: string, line: number, column: number, context: string ): ContractEnforcementIssue { return { type: IssueType.ContractGap, severity, pattern, message, location: { file: filePath, line, column }, context, suggestion: getSuggestion(pattern), }; } function getSuggestion(pattern: DefensivePattern): string { switch (pattern) { case 'as-any': return 'Define a proper type or use type narrowing instead of `as any`.'; case 'as-unknown': return 'Use a single validated type assertion or schema validation instead of `as unknown as`.'; case 'deep-optional-chain': return 'Enforce a non-nullable type at the source to eliminate deep optional chaining.'; case 'nullish-literal-default': return 'Define defaults in a typed config object rather than inline literal fallbacks.'; case 'swallowed-error': return 'Log or propagate errors — silent catch blocks hide failures.'; case 'env-fallback': return 'Use a validated env schema (e.g., Zod) to enforce required variables at startup.'; case 'unnecessary-guard': return 'Make the parameter non-nullable in the type signature to eliminate the guard.'; case 'any-parameter': return 'Define a proper type for this parameter instead of `any`.'; case 'any-return': return 'Define a proper return type instead of `any`.'; case 'sparse-type': return 'Minimize optional properties; use required properties with default values or discriminated unions for better clarity.'; case 'optional-parameter': return 'Avoid optional parameters in internal functions; use default values or overloads to maintain strictness.'; } } function getLineContent(code: string, line: number): string { const lines = code.split('\n'); return (lines[line - 1] || '').trim().slice(0, 120); } function countOptionalChainDepth(node: TSESTree.Node): number { let depth = 0; let current: TSESTree.Node | undefined = node; while (current) { if (current.type === 'MemberExpression' && current.optional) { depth++; current = current.object; } else if (current.type === 'ChainExpression') { current = current.expression; } else if (current.type === 'CallExpression' && current.optional) { depth++; current = current.callee; } else { break; } } return depth; } function isLiteral(node: TSESTree.Node): boolean { if (node.type === 'Literal') return true; if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return true; if ( node.type === 'UnaryExpression' && (node.operator === '-' || node.operator === '+') ) { return isLiteral(node.argument); } return false; } function isProcessEnvAccess(node: TSESTree.Node | undefined): boolean { return ( node?.type === 'MemberExpression' && node.object?.type === 'MemberExpression' && node.object.object?.type === 'Identifier' && node.object.object.name === 'process' && node.object.property?.type === 'Identifier' && node.object.property.name === 'env' ); } function isSstResourceAccess(node: TSESTree.Node): boolean { let current: TSESTree.Node = node; // Handle ChainExpression for optional chaining like Resource.MySecret?.value if (current.type === 'ChainExpression') { current = current.expression; } // Recursively check for 'Resource' identifier at the base of the member chain while (current && current.type === 'MemberExpression') { if ( current.object?.type === 'Identifier' && current.object.name === 'Resource' ) { return true; } current = current.object; } // Also check if the node itself is 'Resource' (rare but possible in some contexts) if (current?.type === 'Identifier' && current.name === 'Resource') { return true; } return false; } function isSwallowedCatch( body: TSESTree.Statement[], filePath: string ): boolean { if (body.length === 0) return true; // UI components often have intentional silent catches for telemetry/analytics const isUiComponent = filePath.endsWith('.tsx') || filePath.endsWith('.jsx'); if (body.length === 1) { const stmt = body[0]; if ( stmt.type === 'ExpressionStatement' && stmt.expression?.type === 'CallExpression' ) { const callee = stmt.expression.callee; // console.error and console.warn are considered "handling" or at least "reporting" if ( callee?.type === 'MemberExpression' && callee.object?.type === 'Identifier' && callee.object.name === 'console' ) { const method = callee.property.type === 'Identifier' ? callee.property.name : ''; if (method === 'error' || method === 'warn') return false; return true; // console.log/info/debug etc. are still considered "swallowed" } // If it's a UI component and looks like telemetry, it's a false positive if (isUiComponent) { let calleeName = ''; if (callee?.type === 'Identifier') calleeName = callee.name; else if ( callee?.type === 'MemberExpression' && callee.property.type === 'Identifier' ) calleeName = callee.property.name; if (/telemetry|analytics|track|logEvent/i.test(calleeName)) { return false; // Not "swallowed" in a bad way } } } if (stmt.type === 'ThrowStatement') return false; } return false; } function isExported(node: TSESTree.Node): boolean { let current: TSESTree.Node | undefined = node; while (current) { if ( current.type === 'ExportNamedDeclaration' || current.type === 'ExportDefaultDeclaration' ) { return true; } // Functions can be inside a block, so stop if we hit one if (current.type === 'BlockStatement') break; current = current.parent; } return false; } function getTypedProperties(node: TSESTree.Node): TSESTree.Node[] { if (node.type === 'TSInterfaceDeclaration') { return node.body.body; } if ( node.type === 'TSTypeAliasDeclaration' && node.typeAnnotation.type === 'TSTypeLiteral' ) { return node.typeAnnotation.members; } return []; } function isInsideCliOptions(node: TSESTree.Node): boolean { let current: TSESTree.Node | undefined = node; while (current) { if ( (current.type === 'Property' || current.type === 'PropertyDefinition') && current.key.type === 'Identifier' && current.key.name === 'getCliOptions' ) { return true; } // Also ignore common commander action handlers if ( current.type === 'CallExpression' && current.callee.type === 'MemberExpression' && current.callee.property.type === 'Identifier' && current.callee.property.name === 'action' ) { return true; } current = (current as any).parent as TSESTree.Node | undefined; } return false; } function isSafeLookup(node: TSESTree.Node): boolean { // Common safe properties to access even with optional chaining const safeProps = new Set(['name', 'label', 'id', 'title', 'description']); let current: TSESTree.Node | undefined = node; // Unwrap ChainExpression if (current.type === 'ChainExpression') { current = current.expression; } // Check if it's a member expression ending in a safe property if (current.type === 'MemberExpression') { if ( current.property.type === 'Identifier' && safeProps.has(current.property.name) ) { return true; } } // Ignore safe reporting/logging calls current = node; while (current) { if (current.type === 'CallExpression') { const callee = current.callee; if ( callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && (callee.object.name === 'console' || callee.object.name === 'chalk') ) { return true; } if ( callee.type === 'Identifier' && /log|print|report/i.test(callee.name) ) { return true; } } // Ignore throw blocks if (current.type === 'ThrowStatement') return true; current = (current as any).parent; } return false; } export function detectDefensivePatterns( filePath: string, code: string, minChainDepth: number = 3 ): DetectionResult { const issues: ContractEnforcementIssue[] = []; const counts: PatternCounts = { ...ZERO_COUNTS }; const totalLines = code.split('\n').length; // Skip env-fallback checks for config files - they intentionally use fallbacks const isConfigFile = filePath.endsWith('.config.ts') || filePath.endsWith('.config.js') || filePath.endsWith('.config.mts') || filePath.endsWith('.config.mjs') || filePath.includes('sst.config.ts') || filePath.endsWith('playwright.config.ts') || filePath.includes('packages/cli/src/commands/'); let ast: TSESTree.Program; try { ast = parse(code, { filePath, loc: true, range: true, jsx: filePath.endsWith('x'), }); } catch { return { issues, counts, totalLines }; } const nodesAtFunctionStart = new WeakSet(); function markFunctionParamNodes(node: TSESTree.Node) { if ( node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' ) { const body = node.body?.type === 'BlockStatement' ? node.body.body : null; if (body && body.length > 0) { nodesAtFunctionStart.add(body[0]); } } } function visit( node: TSESTree.Node | undefined, _parent?: TSESTree.Node, _keyInParent?: string ) { if (!node || typeof node !== 'object') return; markFunctionParamNodes(node); (node as any).parent = _parent; // Pattern: as any if ( node.type === 'TSAsExpression' && node.typeAnnotation?.type === 'TSAnyKeyword' ) { // Ignore if it's acting on an SST resource or process.env, common "necessary escape hatches" if ( !isSstResourceAccess(node.expression) && !isProcessEnvAccess(node.expression) && !isInsideCliOptions(node) && !isSafeLookup(node) ) { counts['as-any']++; issues.push( makeIssue( 'as-any', Severity.Major, '`as any` type assertion bypasses type safety', filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } // Pattern: as unknown if ( node.type === 'TSAsExpression' && node.typeAnnotation?.type === 'TSUnknownKeyword' ) { // Ignore if it's acting on an SST resource or process.env if ( !isSstResourceAccess(node.expression) && !isProcessEnvAccess(node.expression) && !isInsideCliOptions(node) && !isSafeLookup(node) ) { counts['as-unknown']++; issues.push( makeIssue( 'as-unknown', Severity.Major, '`as unknown` double-cast bypasses type safety', filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } // Pattern: deep optional chaining if (node.type === 'ChainExpression') { const depth = countOptionalChainDepth(node); if ( depth >= minChainDepth && !isInsideCliOptions(node) && !isSafeLookup(node) ) { counts['deep-optional-chain']++; issues.push( makeIssue( 'deep-optional-chain', Severity.Minor, `Optional chain depth of ${depth} indicates missing structural guarantees`, filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } // Pattern: nullish coalescing with literal default if ( node.type === 'LogicalExpression' && node.operator === '??' && isLiteral(node.right) ) { // Ignore if it's an SST Resource or process.env - the standard way to handle fallbacks if ( !isSstResourceAccess(node.left) && !isProcessEnvAccess(node.left) && !isInsideCliOptions(node) && !isSafeLookup(node) ) { counts['nullish-literal-default']++; issues.push( makeIssue( 'nullish-literal-default', Severity.Minor, 'Nullish coalescing with literal default suggests missing upstream type guarantee', filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } // Pattern: swallowed error if (node.type === 'TryStatement' && node.handler) { const catchBody = node.handler.body?.body; if (catchBody && isSwallowedCatch(catchBody, filePath)) { counts['swallowed-error']++; issues.push( makeIssue( 'swallowed-error', Severity.Major, 'Error is swallowed in catch block — failures will be silent', filePath, node.handler.loc.start.line, node.handler.loc.start.column, getLineContent(code, node.handler.loc.start.line) ) ); } } // Pattern: process.env.X || default (skip for config files) if ( !isConfigFile && node.type === 'LogicalExpression' && node.operator === '||' && isProcessEnvAccess(node.left) ) { counts['env-fallback']++; issues.push( makeIssue( 'env-fallback', Severity.Minor, 'Environment variable with fallback — use a validated env schema instead', filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } // Pattern: if (!x) return guard at function entry if ( node.type === 'IfStatement' && node.test?.type === 'UnaryExpression' && node.test.operator === '!' ) { const consequent = node.consequent; let isReturn = false; if (consequent.type === 'ReturnStatement') { isReturn = true; } else if ( consequent.type === 'BlockStatement' && consequent.body?.length === 1 && consequent.body[0].type === 'ReturnStatement' ) { isReturn = true; } if (isReturn && nodesAtFunctionStart.has(node)) { counts['unnecessary-guard']++; issues.push( makeIssue( 'unnecessary-guard', Severity.Info, 'Guard clause could be eliminated with non-nullable type at source', filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } // Pattern: any parameter type if ( (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') && node.params ) { for (const param of node.params) { let typeAnno: TSESTree.TypeNode | undefined; // Handle Identifier, AssignmentPattern, RestElement, etc. if ('typeAnnotation' in param && param.typeAnnotation) { typeAnno = (param.typeAnnotation as TSESTree.TSTypeAnnotation) .typeAnnotation; } if (typeAnno?.type === 'TSAnyKeyword') { counts['any-parameter']++; issues.push( makeIssue( 'any-parameter', Severity.Major, 'Parameter typed as `any` bypasses type safety', filePath, param.loc.start.line, param.loc.start.column, getLineContent(code, param.loc.start.line) ) ); } } // Pattern: any return type let returnAnno: TSESTree.TypeNode | undefined; if ('returnType' in node && node.returnType) { returnAnno = (node.returnType as TSESTree.TSTypeAnnotation) .typeAnnotation; } if (returnAnno?.type === 'TSAnyKeyword') { const fnNode = node as TSESTree.FunctionDeclaration; if (fnNode.returnType?.loc) { counts['any-return']++; issues.push( makeIssue( 'any-return', Severity.Major, 'Return type is `any` — callers cannot rely on the result shape', filePath, fnNode.returnType.loc.start.line, fnNode.returnType.loc.start.column, getLineContent(code, fnNode.returnType.loc.start.line) ) ); } } // Pattern: optional parameter in internal functions if (!isExported(node)) { for (const param of node.params) { if ('optional' in param && param.optional) { counts['optional-parameter']++; issues.push( makeIssue( 'optional-parameter', Severity.Minor, 'Optional parameter in internal function — use default values or overloads for better grounding', filePath, param.loc.start.line, param.loc.start.column, getLineContent(code, param.loc.start.line) ) ); } } } } // Pattern: sparse type (too many optional properties) if ( node.type === 'TSInterfaceDeclaration' || node.type === 'TSTypeAliasDeclaration' ) { const properties = getTypedProperties(node); const totalProps = properties.filter( (p) => p.type === 'TSPropertySignature' ).length; if (totalProps >= 3) { const optionalProps = properties.filter( (p) => p.type === 'TSPropertySignature' && p.optional ).length; const ratio = optionalProps / totalProps; if (ratio > 0.25) { let sev = Severity.Info; if (ratio > 0.75) sev = Severity.Major; else if (ratio > 0.5) sev = Severity.Minor; counts['sparse-type']++; issues.push( makeIssue( 'sparse-type', sev, `Sparse type detected: ${optionalProps} of ${totalProps} properties (${Math.round( ratio * 100 )}%) are optional`, filePath, node.loc.start.line, node.loc.start.column, getLineContent(code, node.loc.start.line) ) ); } } } // Recurse for (const key in node) { if (key === 'loc' || key === 'range' || key === 'parent') continue; const child = (node as Record)[key]; if (Array.isArray(child)) { for (const item of child) { if (item && typeof item.type === 'string') { visit(item as TSESTree.Node, node, key); } } } else if ( child && typeof child === 'object' && typeof child.type === 'string' ) { visit(child as TSESTree.Node, node, key); } } } visit(ast); return { issues, counts, totalLines }; }