UNPKG

@sun-asterisk/sunlint

Version:

☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

752 lines (625 loc) 24.3 kB
// Enhanced symbol-based analyzer for C014 const { SyntaxKind } = require('ts-morph'); class C014SymbolBasedAnalyzer { constructor(semanticEngine = null) { this.semanticEngine = semanticEngine; this.verbose = false; // Configuration this.config = { // Built-in classes that are allowed allowedBuiltins: [ 'Date', 'Array', 'Object', 'String', 'Number', 'Boolean', 'RegExp', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Promise', 'Error', 'TypeError', 'FormData', 'Headers', 'Request', 'Response', 'URLSearchParams', 'URL', 'Blob', 'File', 'Buffer', 'AbortController', 'AbortSignal', 'TextEncoder', 'TextDecoder', 'MessageChannel', 'MessagePort', 'Worker', 'SharedWorker', 'EventSource', 'WebSocket' ], // Value objects/DTOs that are typically safe to instantiate allowedValueObjects: [ 'Money', 'Price', 'Currency', 'Quantity', 'Amount', 'Email', 'Phone', 'Address', 'Name', 'Id', 'UserId', 'UUID', 'Timestamp', 'Duration', 'Range' ], // Infrastructure patterns that suggest external dependencies infraPatterns: [ 'Client', 'Repository', 'Service', 'Gateway', 'Adapter', 'Provider', 'Factory', 'Builder', 'Manager', 'Handler', 'Controller', 'Processor', 'Validator', 'Logger' ], // DI decorators that indicate proper injection diDecorators: [ 'Injectable', 'Inject', 'Autowired', 'Component', 'Service', 'Repository', 'Controller', 'autoInjectable' ], // Patterns to exclude from analysis excludePatterns: [ '**/*.test.ts', '**/*.spec.ts', '**/*.test.js', '**/*.spec.js', '**/tests/**', '**/test/**', '**/migration/**', '**/scripts/**' ] }; } async initialize(semanticEngine = null) { if (semanticEngine) { this.semanticEngine = semanticEngine; } this.verbose = semanticEngine?.verbose || false; } async analyzeFileBasic(filePath, options = {}) { const violations = []; try { // Try different approaches to get the source file let sourceFile = this.semanticEngine.project.getSourceFile(filePath); // If not found by full path, try by filename if (!sourceFile) { const fileName = filePath.split('/').pop(); const allFiles = this.semanticEngine.project.getSourceFiles(); sourceFile = allFiles.find(f => f.getBaseName() === fileName); } // If still not found, try to add the file if (!sourceFile) { try { if (require('fs').existsSync(filePath)) { sourceFile = this.semanticEngine.project.addSourceFileAtPath(filePath); } } catch (addError) { // Fall through to error below } } if (!sourceFile) { throw new Error(`Source file not found: ${filePath}`); } if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Analyzing DI violations in ${filePath.split('/').pop()}`); } // Skip test files and excluded patterns if (this.shouldSkipFile(filePath)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping excluded file ${filePath.split('/').pop()}`); } return violations; } // Find all new expressions that might violate DI principles const newExpressions = this.findProblematicNewExpressions(sourceFile); for (const expr of newExpressions) { if (this.isDependencyInjectionViolation(expr, sourceFile)) { violations.push({ ruleId: 'C014', message: this.buildViolationMessage(expr), filePath: filePath, line: expr.line, column: expr.column, severity: 'warning', category: 'design', confidence: expr.confidence, suggestion: this.buildSuggestion(expr) }); } } if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Found ${violations.length} DI violations`); } return violations; } catch (error) { if (this.verbose) { console.error(`[DEBUG] ❌ C014: Symbol analysis error: ${error.message}`); } throw error; } } findProblematicNewExpressions(sourceFile) { const expressions = []; function traverse(node) { if (node.getKind() === SyntaxKind.NewExpression) { const newExpr = node; const expression = newExpr.getExpression(); // Get class name and context information const className = this.getClassName(expression); const position = sourceFile.getLineAndColumnAtPos(newExpr.getStart()); const context = this.analyzeContext(newExpr); if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Found new expression: ${className} at line ${position.line}`); } if (className) { expressions.push({ node: newExpr, className: className, line: position.line, column: position.column, context: context, confidence: this.calculateConfidence(className, context) }); } } // Traverse children node.forEachChild(child => traverse.call(this, child)); } traverse.call(this, sourceFile); if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Found ${expressions.length} new expressions total`); } return expressions; } getClassName(expression) { if (expression.getKind() === SyntaxKind.Identifier) { return expression.getText(); } // Handle qualified names like MyNamespace.MyClass if (expression.getKind() === SyntaxKind.PropertyAccessExpression) { return expression.getName(); } return null; } analyzeContext(newExpressionNode) { const context = { isInConstructor: false, isAssignedToThis: false, isInMethod: false, isLocalVariable: false, isReturnValue: false, isImmediateUse: false, parentFunction: null, hasDecorators: false }; let current = newExpressionNode.getParent(); while (current) { switch (current.getKind()) { case SyntaxKind.Constructor: context.isInConstructor = true; context.parentFunction = current; break; case SyntaxKind.MethodDeclaration: context.isInMethod = true; context.parentFunction = current; break; case SyntaxKind.BinaryExpression: // Check if it's assignment to this.property const binaryExpr = current; if (binaryExpr.getOperatorToken().getKind() === SyntaxKind.EqualsToken) { const left = binaryExpr.getLeft(); if (left.getKind() === SyntaxKind.PropertyAccessExpression) { const propAccess = left; if (propAccess.getExpression().getKind() === SyntaxKind.ThisKeyword) { context.isAssignedToThis = true; } } } break; case SyntaxKind.PropertyDeclaration: // Check if this new expression is in a class property initializer const propDecl = current; const initializer = propDecl.getInitializer(); if (initializer && this.containsNewExpression(initializer, newExpressionNode)) { context.isAssignedToThis = true; // Class property is effectively "this.property" } break; case SyntaxKind.VariableDeclaration: context.isLocalVariable = true; break; case SyntaxKind.ReturnStatement: context.isReturnValue = true; break; case SyntaxKind.CallExpression: // Check for immediate method call like new Date().getTime() const callExpr = current; if (callExpr.getExpression() === newExpressionNode) { context.isImmediateUse = true; } break; case SyntaxKind.ClassDeclaration: // Check for DI decorators on the class context.hasDecorators = this.hasDecorators(current, this.config.diDecorators); break; } current = current.getParent(); } return context; } containsNewExpression(node, targetNewExpr) { if (node === targetNewExpr) { return true; } let found = false; node.forEachChild(child => { if (found) return; if (this.containsNewExpression(child, targetNewExpr)) { found = true; } }); return found; } isDependencyInjectionViolation(expr, sourceFile) { const { className, context } = expr; if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Checking violation for ${className}:`, { isAssignedToThis: context.isAssignedToThis, isInConstructor: context.isInConstructor, isInMethod: context.isInMethod, isLocalVariable: context.isLocalVariable, isImmediateUse: context.isImmediateUse, isReturnValue: context.isReturnValue, hasDecorators: context.hasDecorators }); } // 1. Skip built-in JavaScript/DOM classes if (this.config.allowedBuiltins.includes(className)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - allowed builtin`); } return false; } // 2. Skip exception/error classes if (this.isExceptionClass(className, sourceFile)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - Exception/Error class`); } return false; } // 4. Skip entity/model classes (data structures) if (this.isEntityClass(className)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - Entity/Model class`); } return false; } // 5. Skip command pattern classes (value objects for operations) if (this.isCommandPattern(className)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - Command pattern class`); } return false; } // 6. Skip value objects/DTOs (configurable) if (this.config.allowedValueObjects.includes(className)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - allowed value object`); } return false; } // 3. Skip if it's immediate usage (not stored as dependency) if (context.isImmediateUse || context.isReturnValue) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - immediate use or return value`); } return false; } // 4. Skip Singleton pattern (self-instantiation in getInstance-like methods) if (this.isSingletonPattern(className, context, sourceFile)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - Singleton pattern`); } return false; } // 5. Skip if it's a local variable in method (not dependency field) UNLESS it's infrastructure if (context.isInMethod && context.isLocalVariable && !context.isAssignedToThis) { // Exception: Still flag if it's infrastructure dependency even as local variable if (this.isLikelyExternalDependency(className, sourceFile)) { if (this.verbose) { console.log(`[DEBUG] ✅ C014: ${className} is violation - infrastructure dependency even as local variable`); } return true; } if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - local variable in method`); } return false; } // 6. Main heuristic: Flag if assigned to this.* (field or in constructor/method) if (context.isAssignedToThis) { // Check if target class suggests external dependency if (this.isLikelyExternalDependency(className, sourceFile)) { if (this.verbose) { console.log(`[DEBUG] ✅ C014: ${className} is violation - assigned to this and external dependency`); } return true; } } // 7. Skip if it's service locator pattern (centralized API client configuration) if (this.isServiceLocatorPattern(context, sourceFile)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Skipping ${className} - Service locator pattern`); } return false; } // 8. Flag if class has infrastructure patterns and no DI decorators if (this.hasInfraPattern(className) && !context.hasDecorators) { if (this.verbose) { console.log(`[DEBUG] ✅ C014: ${className} is violation - has infra pattern`); } return true; } if (this.verbose) { console.log(`[DEBUG] 🔍 C014: ${className} is NOT a violation`); } return false; } isLikelyExternalDependency(className, sourceFile) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Checking if ${className} is external dependency`); } // Check if class name suggests infrastructure/external service const hasInfraPattern = this.config.infraPatterns.some(pattern => className.includes(pattern) ); if (hasInfraPattern) { if (this.verbose) { console.log(`[DEBUG] ✅ C014: ${className} has infra pattern`); } return true; } // Check import statements to see if it's from external module const imports = sourceFile.getImportDeclarations(); for (const importDecl of imports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { if (namedImport.getName() === className) { const moduleSpecifier = importDecl.getModuleSpecifierValue(); if (this.verbose) { console.log(`[DEBUG] 🔍 C014: ${className} imported from: ${moduleSpecifier}`); } // Check if imported from infrastructure/adapter paths if (this.isInfrastructurePath(moduleSpecifier)) { if (this.verbose) { console.log(`[DEBUG] ✅ C014: ${className} from infrastructure path`); } return true; } } } } if (this.verbose) { console.log(`[DEBUG] 🔍 C014: ${className} is NOT external dependency`); } return false; } isInfrastructurePath(modulePath) { const infraPaths = [ 'infra', 'infrastructure', 'adapters', 'clients', 'repositories', 'services', 'gateways', 'providers' ]; // Check explicit infra path keywords if (infraPaths.some(path => modulePath.includes(path))) { return true; } // Check common external infrastructure packages const infraPackages = [ '@aws-sdk/', 'aws-sdk', 'redis', 'mysql', 'postgresql', 'prisma', 'mongoose', 'sequelize', 'typeorm', 'knex', 'pg', 'mysql2', 's3-sync-client', 'firebase', 'googleapis', 'stripe', 'twilio', 'sendgrid', 'nodemailer', 'kafka', 'rabbitmq', 'elasticsearch', 'mongodb', 'cassandra' ]; return infraPackages.some(pkg => modulePath.includes(pkg)); } hasInfraPattern(className) { return this.config.infraPatterns.some(pattern => className.includes(pattern) ); } isExceptionClass(className, sourceFile) { // First check by naming convention (fast path) const errorPatterns = [ 'Error', 'Exception', 'Fault', 'Failure' ]; const hasErrorName = errorPatterns.some(pattern => className.endsWith(pattern) || className.includes(pattern) ); if (hasErrorName) { return true; } // Check inheritance hierarchy using semantic analysis if (this.semanticEngine && sourceFile) { try { return this.inheritsFromErrorClass(className, sourceFile); } catch (error) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Could not check inheritance for ${className}: ${error.message}`); } // Fall back to name-based check only return false; } } return false; } inheritsFromErrorClass(className, sourceFile) { // Find class declaration in current file const classDecl = sourceFile.getClasses().find(cls => cls.getName() === className); if (!classDecl) { // Class might be imported, try to resolve it return this.isImportedErrorClass(className, sourceFile); } // Check direct inheritance const extendsClauses = classDecl.getExtends(); if (!extendsClauses) { return false; } const baseClassName = extendsClauses.getExpression().getText(); // Check if directly extends Error-like class const errorBaseClasses = [ 'Error', 'TypeError', 'ReferenceError', 'SyntaxError', 'RangeError', 'EvalError', 'URIError', 'AggregateError' ]; if (errorBaseClasses.includes(baseClassName)) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: ${className} extends ${baseClassName} (Error class)`); } return true; } // Recursively check inheritance chain return this.inheritsFromErrorClass(baseClassName, sourceFile); } isImportedErrorClass(className, sourceFile) { // Check imports to see if className is imported from error/exception modules const imports = sourceFile.getImportDeclarations(); for (const importDecl of imports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { if (namedImport.getName() === className) { const moduleSpecifier = importDecl.getModuleSpecifierValue(); // Check if imported from error/exception related modules const errorModulePatterns = [ 'error', 'exception', 'http-exception', 'custom-error', '../exceptions', './exceptions', '/errors/', '/exceptions/' ]; const isFromErrorModule = errorModulePatterns.some(pattern => moduleSpecifier.toLowerCase().includes(pattern) ); if (isFromErrorModule) { if (this.verbose) { console.log(`[DEBUG] 🔍 C014: ${className} imported from error module: ${moduleSpecifier}`); } return true; } } } } return false; } isEntityClass(className) { // Common entity/model class patterns const entityPatterns = [ 'Entity', 'Model', 'Schema', 'Document', 'Dto', 'DTO' ]; return entityPatterns.some(pattern => className.endsWith(pattern) ); } isCommandPattern(className) { // Command pattern classes (value objects for operations) const commandPatterns = [ 'Command', 'Request', 'Query', 'Operation', 'Action', 'Task', 'Job' ]; return commandPatterns.some(pattern => className.endsWith(pattern) ); } isServiceLocatorPattern(context, sourceFile) { // Check if we're in an object literal assignment that looks like service locator if (!context.isLocalVariable) { return false; } // Additional check: if file contains many similar API instantiations, // it's likely a service locator pattern const fileText = sourceFile.getFullText(); const newExpressionCount = (fileText.match(/new \w+Api\(\)/g) || []).length; if (newExpressionCount >= 5) { // Many API instantiations suggest service locator pattern if (this.verbose) { console.log(`[DEBUG] 🔍 C014: Found ${newExpressionCount} API instantiations - likely service locator`); } return true; } // Check for service locator variable names in file text const serviceLocatorPatterns = [ 'apiClient', 'serviceContainer', 'container', 'services', 'clients', 'providers', 'factories', 'registry' ]; const isServiceLocator = serviceLocatorPatterns.some(pattern => fileText.includes(`export const ${pattern}`) || fileText.includes(`const ${pattern}`) ); if (isServiceLocator && this.verbose) { console.log(`[DEBUG] 🔍 C014: Detected service locator pattern from variable name`); } return isServiceLocator; } hasDecorators(node, decoratorNames) { const decorators = node.getDecorators?.() || []; return decorators.some(decorator => { const decoratorText = decorator.getText(); return decoratorNames.some(name => decoratorText.includes(name)); }); } shouldSkipFile(filePath) { return this.config.excludePatterns.some(pattern => { const regex = pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*'); return new RegExp(regex).test(filePath); }); } /** * Check if this is a Singleton pattern (self-instantiation) */ isSingletonPattern(className, context, sourceFile) { // Must be in a method (not constructor) if (!context.isInMethod || context.isInConstructor) { return false; } // Must be instantiating the same class we're in const classDeclaration = this.findContainingClass(context, sourceFile); if (!classDeclaration) { return false; } const currentClassName = classDeclaration.getName(); if (currentClassName !== className) { return false; } // Method name should suggest singleton (getInstance, instance, create, etc.) const methodName = context.parentFunction?.getName?.() || ''; const singletonMethods = [ 'getInstance', 'instance', 'getinstance', 'create', 'createInstance', 'singleton', 'getSingleton', 'getSharedInstance', 'shared' ]; const isSingletonMethod = singletonMethods.some(pattern => methodName.toLowerCase().includes(pattern.toLowerCase()) ); // Must be a static method const isStaticMethod = context.parentFunction?.getModifiers?.() ?.some(modifier => modifier.getKind() === SyntaxKind.StaticKeyword) || false; if (this.verbose && isSingletonMethod && isStaticMethod) { console.log(`[DEBUG] 🔍 C014: Detected Singleton pattern: ${currentClassName}.${methodName}()`); } return isSingletonMethod && isStaticMethod; } /** * Find the containing class declaration */ findContainingClass(context, sourceFile) { let current = context.parentFunction?.getParent(); while (current) { if (current.getKind() === SyntaxKind.ClassDeclaration) { return current; } current = current.getParent(); } return null; } calculateConfidence(className, context) { let confidence = 0.6; // Base confidence // Increase confidence for infrastructure patterns if (this.hasInfraPattern(className)) { confidence += 0.2; } // Increase confidence if assigned to this.* (dependency field) if (context.isAssignedToThis) { confidence += 0.2; } // Decrease confidence for value objects if (this.config.allowedValueObjects.includes(className)) { confidence -= 0.3; } // Decrease confidence if has DI decorators if (context.hasDecorators) { confidence -= 0.4; } return Math.max(0.3, Math.min(1.0, confidence)); } buildViolationMessage(expr) { const { className, context } = expr; if (context.isInConstructor && context.isAssignedToThis) { return `Direct instantiation of '${className}' in constructor. Consider injecting this dependency instead of creating it directly.`; } if (context.isInMethod && context.isAssignedToThis) { return `Direct instantiation of '${className}' assigned to instance field. Consider injecting this dependency.`; } return `Direct instantiation of '${className}'. Consider using dependency injection or factory pattern.`; } buildSuggestion(expr) { const { className, context } = expr; if (context.isInConstructor) { return `Inject ${className} via constructor parameter: constructor(private ${className.toLowerCase()}: ${className})`; } return `Consider injecting ${className} as a dependency or using a factory pattern`; } } module.exports = C014SymbolBasedAnalyzer;