UNPKG

@sun-asterisk/sunlint

Version:

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

826 lines (717 loc) 29.1 kB
/** * Analysis Orchestrator - Plugin-Based Architecture * Following Rule C005: Single responsibility - orchestrate analysis plugins * Following Rule C014: Dependency injection - inject analysis engines * Following Rule C015: Use domain language - clear orchestration terms */ const chalk = require('chalk'); const path = require('path'); const fs = require('fs'); const AnalysisEngineInterface = require('./interfaces/analysis-engine.interface'); const SunlintRuleAdapter = require('./adapters/sunlint-rule-adapter'); const PerformanceOptimizer = require('./performance-optimizer'); class AnalysisOrchestrator { constructor() { this.engines = new Map(); // Plugin registry this.initialized = false; this.defaultTimeout = 30000; // 30 seconds default timeout this.ruleAdapter = SunlintRuleAdapter.getInstance(); this.enginesConfigPath = path.join(__dirname, '..', 'config', 'engines', 'engines.json'); this.performanceOptimizer = new PerformanceOptimizer(); } /** * Auto-load engines from configuration * Following Rule C006: Verb-noun naming * @param {Object} config - Configuration for engines */ async loadEnginesFromConfig(config = {}) { try { // Load engines config let enginesConfig = {}; if (fs.existsSync(this.enginesConfigPath)) { const configData = fs.readFileSync(this.enginesConfigPath, 'utf8'); enginesConfig = JSON.parse(configData); } // Load each configured engine const enabledEngines = config.enabledEngines || enginesConfig.defaultEngines || Object.keys(enginesConfig.engines || {}); for (const engineName of enabledEngines) { const engineConfig = enginesConfig.engines?.[engineName]; if (!engineConfig || !engineConfig.enabled) { continue; } try { // Load engine module const enginePath = path.resolve(__dirname, '..', engineConfig.path); const EngineClass = require(enginePath); // Create and register engine instance const engine = new EngineClass(); this.registerEngine(engine); } catch (error) { console.warn(chalk.yellow(`⚠️ Failed to load engine ${engineName}:`), error.message); } } } catch (error) { console.warn(chalk.yellow(`⚠️ Failed to load engines config:`), error.message); // Fall back to manual registration if config fails } } /** * Register an analysis engine plugin * Following Rule C014: Dependency injection - register engines * Following Rule C006: Verb-noun naming * @param {AnalysisEngineInterface} engine - Engine to register * @param {Object} options - Options including verbose flag */ registerEngine(engine, options = {}) { if (!(engine instanceof AnalysisEngineInterface)) { throw new Error('Engine must implement AnalysisEngineInterface'); } if (this.engines.has(engine.id)) { if (options.verbose) { console.warn(chalk.yellow(`⚠️ Engine ${engine.id} already registered, replacing...`)); } } this.engines.set(engine.id, engine); if (options.verbose) { console.log(chalk.green(`✅ Registered engine: ${engine.id} v${engine.version}`)); if (!this.initialized) { console.log(chalk.gray(` Supported languages: ${engine.supportedLanguages.join(', ')}`)); // Note: Supported rules count will be shown after initialization } } } /** * Initialize all registered engines * Following Rule C006: Verb-noun naming * @param {Object} config - Configuration for engines */ async initialize(config) { if (this.initialized) { return; } // Auto-load engines from config if none are registered if (this.engines.size === 0) { await this.loadEnginesFromConfig(config); } if (config.verbose) { console.log(chalk.blue(`🔧 Initializing ${this.engines.size} analysis engines...`)); } // Initialize rule adapter await this.ruleAdapter.initialize(); // Initialize enabled engines const enabledEngines = config.enabledEngines || Array.from(this.engines.keys()); for (const engineName of enabledEngines) { const engine = this.engines.get(engineName); if (!engine) { if (config.verbose) { console.warn(chalk.yellow(`⚠️ Engine ${engineName} not registered, skipping...`)); } continue; } try { await engine.initialize(config[`${engineName}Config`] || {}); if (config.verbose) { console.log(chalk.green(`✅ ${engineName} engine initialized`)); console.log(chalk.gray(` Supported rules: ${engine.getSupportedRules().length} rules`)); } } catch (error) { console.error(chalk.red(`❌ Failed to initialize ${engineName} engine:`), error.message); // Remove failed engine from registry this.engines.delete(engineName); } } if (this.engines.size === 0) { throw new Error('No analysis engines successfully initialized'); } this.initialized = true; if (config.verbose) { console.log(chalk.blue(`🚀 All engines initialized successfully`)); } } /** * Run analysis using appropriate engines * Following Rule C005: Single responsibility - orchestrate analysis * Following Rule C006: Verb-noun naming * @param {Object[]} rulesToRun - Rules to analyze * @param {Object} options - Analysis options * @param {Object} config - Configuration * @returns {Promise<Object>} Combined analysis results */ async runAnalysis(rulesToRun, options, config = {}) { try { // Initialize engines if not already done if (!this.initialized) { await this.initialize(config); } if (this.engines.size === 0) { throw new Error('No analysis engines registered'); } // Initialize performance optimizer await this.performanceOptimizer.initialize(config); // Apply performance optimizations to files and rules const { optimizedFiles, optimizedRules, performanceMetrics } = await this.performanceOptimizer.optimizeAnalysis( options.files || [options.input], rulesToRun, config ); if (!options.quiet) { // Group rules by category const rulesByCategory = this.groupRulesByCategory(optimizedRules); const categoryBreakdown = Object.entries(rulesByCategory) .map(([cat, rules]) => `${cat}:${rules.length}`) .join(' · '); // Professional startup display console.log(); console.log(chalk.cyan(` ➜ Rules `) + chalk.white(`${optimizedRules.length}`) + chalk.gray(` (${categoryBreakdown})`)); console.log(chalk.cyan(` ➜ Files `) + chalk.white(`${optimizedFiles.length}`) + chalk.gray(` targeted`)); // Display rules by category with truncation for (const [category, rules] of Object.entries(rulesByCategory)) { const ruleIds = rules.map(r => r.id || r).sort(); const displayRules = ruleIds.length > 8 ? ruleIds.slice(0, 8).join(', ') + chalk.gray(` +${ruleIds.length - 8} more`) : ruleIds.join(', '); console.log(chalk.cyan(` ➜ ${category} `) + chalk.gray(displayRules)); } // Separator before progress console.log(); console.log(chalk.gray(' ─────────────────────────────────────────────────────────────')); console.log(); } // Group rules by their preferred engines const engineGroups = this.groupRulesByEngine(optimizedRules, config); // Calculate total batches for progress tracking let totalBatches = 0; const engineBatchInfo = new Map(); for (const [engineName, rules] of engineGroups) { const ruleBatches = this.performanceOptimizer.createRuleBatches(rules, config); engineBatchInfo.set(engineName, ruleBatches); totalBatches += ruleBatches.length; } // Run analysis on each engine with batching const results = []; let completedBatches = 0; let totalViolationsFound = 0; const startTime = Date.now(); let currentEngineName = null; for (const [engineName, rules] of engineGroups) { const engine = this.engines.get(engineName); if (!engine) { continue; } // Display engine header when switching to new engine if (!options.quiet && engineName !== currentEngineName) { currentEngineName = engineName; const engineLabel = this.getEngineDisplayName(engineName); const engineRulesCount = rules.length; console.log(); console.log(chalk.cyan(` ⚙ ${engineLabel}`) + chalk.gray(` · ${engineRulesCount} rules · ${optimizedFiles.length} files`)); } // Get pre-calculated batches const ruleBatches = engineBatchInfo.get(engineName); for (let i = 0; i < ruleBatches.length; i++) { const batch = ruleBatches[i]; const batchNumber = i + 1; const overallProgress = Math.round((completedBatches / totalBatches) * 100); const batchStartTime = Date.now(); // Log batch start - multi-line format for easier debugging if (!options.quiet) { const batchLabel = `Batch ${completedBatches + 1}/${totalBatches}`; console.log(); console.log(chalk.gray(` ┌─ ${batchLabel} `) + chalk.dim(`(${overallProgress}%)`)); // Display each rule on separate line this.displayBatchRulesMultiLine(batch); } try { const engineResult = await this.runEngineWithOptimizations( engine, optimizedFiles, batch, options, { batchNumber, totalBatches: ruleBatches.length, overallProgress } ); results.push({ engine: engineName, batch: batchNumber, rules: batch.map(r => r.id), ...engineResult }); const batchViolations = this.countViolations(engineResult); completedBatches++; totalViolationsFound += batchViolations; const batchDuration = ((Date.now() - batchStartTime) / 1000).toFixed(2); // Log batch result if (!options.quiet) { if (batchViolations > 0) { console.log(chalk.gray(` └─ `) + chalk.yellow(`⚠ ${batchViolations} issues`) + chalk.gray(` · ${batchDuration}s`)); } else { console.log(chalk.gray(` └─ `) + chalk.green(`✓ passed`) + chalk.gray(` · ${batchDuration}s`)); } } } catch (error) { completedBatches++; const batchDuration = ((Date.now() - batchStartTime) / 1000).toFixed(2); if (!options.quiet) { console.log(chalk.gray(` └─ `) + chalk.red(`✗ Error: ${error.message}`) + chalk.gray(` · ${batchDuration}s`)); } } } } // Show final result if (!options.quiet) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(); console.log(chalk.gray(' ─────────────────────────────────────────────────────────────')); console.log(); if (totalViolationsFound === 0) { console.log(' ' + chalk.green('✓ All checks passed') + chalk.gray(` · ${elapsed}s`)); } else { console.log(' ' + chalk.yellow(`⚠ ${totalViolationsFound} issues`) + chalk.gray(` · ${elapsed}s`)); } console.log(); } // Merge results and add performance metrics const mergedResults = this.mergeEngineResults(results, options, optimizedFiles.length); mergedResults.performance = performanceMetrics; return mergedResults; } catch (error) { console.error(chalk.red('❌ Analysis orchestration failed:'), error.message); throw error; } } /** * Group rules by their preferred analysis engines * Following Rule C005: Single responsibility * Following Rule C006: Verb-noun naming * @param {Object[]} rulesToRun - Rules to group * @param {Object} config - Configuration with engine preferences * @returns {Map} Map of engine names to rule arrays */ groupRulesByEngine(rulesToRun, config) { const groups = new Map(); const skippedRules = []; if (config.verbose) { console.log(`📊 [Orchestrator] Grouping ${rulesToRun.length} rules by engine...`); } for (const rule of rulesToRun) { if (config.verbose) { console.log(`🔄 [Orchestrator] Processing rule ${rule.id}`); } // Determine engine preference order const enginePreference = this.getEnginePreference(rule, config); if (config.verbose) { console.log(`🎯 [Orchestrator] Engine preference for ${rule.id}:`, enginePreference); } const selectedEngine = this.selectBestEngine(enginePreference, rule, config); // If rule is skipped (no engine supports it when specific engine requested) if (selectedEngine === null) { skippedRules.push(rule); if (config.verbose) { console.log(`⚠️ [Orchestrator] Skipped rule ${rule.id} - not supported by requested engine`); } continue; } if (config.verbose) { console.log(`✅ [Orchestrator] Selected engine for ${rule.id}: ${selectedEngine}`); } if (!groups.has(selectedEngine)) { groups.set(selectedEngine, []); } groups.get(selectedEngine).push(rule); } if (config.verbose) { console.log(`📋 [Orchestrator] Final groups:`, Array.from(groups.entries()).map(([k, v]) => [k, v.length])); } return groups; } /** * Get engine preference for a rule * Following Rule C006: Verb-noun naming * @param {Object} rule - Rule object * @param {Object} config - Configuration * @returns {string[]} Array of engine names in preference order */ getEnginePreference(rule, config) { // If user specified a specific engine via --engine option, use only that engine if (config.requestedEngine) { // Handle "auto" engine selection if (config.requestedEngine === 'auto') { // Auto-select best engines: default to heuristic, add eslint for JS/TS return ['heuristic', 'eslint']; } return [config.requestedEngine]; } // Special preference for C047: Always use semantic analysis (heuristic engine) if (rule.id === 'C047') { return ['heuristic', 'openai']; } // Check config-level rule preferences const ruleConfig = config.rules?.[rule.id]; if (ruleConfig?.engines) { return ruleConfig.engines; } // Check CLI --eslint-integration flag (high priority) if (config.eslintIntegration) { // ESLint integration: prioritize ESLint engine for all rules // ESLint engine can handle JS/TS rules, heuristic handles others return ['eslint', 'heuristic', 'openai']; } // Check rule analyzer field for compatibility if (rule.analyzer) { if (rule.analyzer === 'eslint' || rule.analyzer === 'typescript') { return ['eslint', 'heuristic']; } if (rule.analyzer.includes('heuristic')) { return ['heuristic', 'openai']; } } // Default preference order return ['heuristic', 'openai', 'eslint']; } /** * Select best available engine for a rule * Following Rule C006: Verb-noun naming * @param {string[]} preferences - Engine preferences in order * @param {Object} rule - Rule object * @param {Object} config - Configuration with verbose flag * @returns {string|null} Selected engine name or null if no engine supports the rule */ selectBestEngine(preferences, rule, config) { if (config.verbose) { console.log(`🎯 [Orchestrator] Selecting engine for rule ${rule.id}, preferences:`, preferences); } // If user specified a specific engine (--engine=eslint), only use that engine // Don't fallback to other engines to maintain consistency const isSpecificEngineRequested = config.requestedEngine && preferences.length === 1; for (const engineName of preferences) { const engine = this.engines.get(engineName); if (config.verbose) { console.log(`🔍 [Orchestrator] Checking engine ${engineName}: exists=${!!engine}`); } if (engine && engine.isRuleSupported(rule.id)) { if (config.verbose) { console.log(`✅ [Orchestrator] Selected engine ${engineName} for rule ${rule.id}`); } return engineName; } } // If specific engine is requested and it doesn't support the rule, skip fallback if (isSpecificEngineRequested) { if (config.verbose) { console.log(`⚠️ [Orchestrator] Rule ${rule.id} not supported by requested engine ${preferences[0]}, skipping`); } return null; // Skip this rule } if (config.verbose) { console.log(`🔄 [Orchestrator] No preferred engine supports ${rule.id}, checking all engines...`); } // Fallback to first available engine that supports the rule (only when no specific engine requested) for (const [engineName, engine] of this.engines) { if (config.verbose) { console.log(`🔍 [Orchestrator] Fallback checking engine ${engineName}`); } if (engine.isRuleSupported(rule.id)) { if (config.verbose) { console.log(`✅ [Orchestrator] Fallback selected engine ${engineName} for rule ${rule.id}`); } return engineName; } } if (config.verbose) { console.log(`🚨 [Orchestrator] No engine supports rule ${rule.id}, falling back to heuristic`); } // Final fallback to 'heuristic' (most flexible) return 'heuristic'; } /** * Run engine analysis with timeout protection and performance optimizations * Following Rule C006: Verb-noun naming * @param {AnalysisEngineInterface} engine - Engine to run * @param {string[]} files - Files to analyze * @param {Object[]} rules - Rules to apply * @param {Object} options - Analysis options * @param {Object} batchInfo - Batch context information * @returns {Promise<Object>} Engine results */ async runEngineWithOptimizations(engine, files, rules, options, batchInfo = {}) { // Dynamic timeout based on file count and rules const adaptiveTimeout = this.performanceOptimizer.calculateAdaptiveTimeout( files.length, rules.length, options.timeout || this.defaultTimeout ); const enhancedOptions = { ...options, timeout: adaptiveTimeout, batchInfo }; try { return await Promise.race([ engine.analyze(files, rules, enhancedOptions), new Promise((_, reject) => setTimeout(() => reject(new Error( `Engine ${engine.name} batch ${batchInfo.batchNumber || 1} timed out after ${adaptiveTimeout}ms` )), adaptiveTimeout) ) ]); } catch (error) { // Enhanced error context for debugging const errorContext = { engine: engine.name, filesCount: files.length, rulesCount: rules.length, timeout: adaptiveTimeout, batch: batchInfo }; // Wrap error with context const enhancedError = new Error(`${error.message} (Context: ${JSON.stringify(errorContext)})`); enhancedError.originalError = error; enhancedError.context = errorContext; throw enhancedError; } } /** * Run engine analysis with timeout protection (legacy method for backward compatibility) * Following Rule C006: Verb-noun naming * @param {AnalysisEngineInterface} engine - Engine to run * @param {string[]} files - Files to analyze * @param {Object[]} rules - Rules to apply * @param {Object} options - Analysis options * @returns {Promise<Object>} Engine results */ async runEngineWithTimeout(engine, files, rules, options) { return this.runEngineWithOptimizations(engine, files, rules, options); } /** * Merge results from multiple engines * Following Rule C005: Single responsibility * Following Rule C006: Verb-noun naming * @param {Object[]} engineResults - Results from all engines * @param {Object} options - Analysis options * @returns {Object} Merged results */ mergeEngineResults(engineResults, options, actualFilesCount = 0) { const mergedResults = { results: [], summary: { totalEngines: engineResults.length, totalBatches: engineResults.length, totalViolations: 0, totalFiles: 0, engines: {} }, metadata: { timestamp: new Date().toISOString(), orchestrator: 'sunlint-v2', version: '2.0.0' } }; // Track unique engines for summary const uniqueEngines = new Set(); // Combine results from all engines for (const engineResult of engineResults) { uniqueEngines.add(engineResult.engine); // Add engine-specific results with validation if (engineResult.results && Array.isArray(engineResult.results)) { // Filter out invalid entries (non-objects or config objects) const validResults = engineResult.results.filter(result => { if (!result || typeof result !== 'object') return false; // Skip objects that look like metadata/config if (result.semanticEngine || result.project || result._context) return false; // Must have either file/filePath or be a valid result object return result.file || result.filePath || result.violations || result.messages; }); mergedResults.results.push(...validResults); } // Track engine statistics const violationCount = this.countViolations(engineResult); const engineName = engineResult.engine; if (!mergedResults.summary.engines[engineName]) { mergedResults.summary.engines[engineName] = { rules: [], violations: 0, files: 0, batches: 0 }; } // Accumulate engine statistics across batches mergedResults.summary.engines[engineName].rules.push(...(engineResult.rules || [])); mergedResults.summary.engines[engineName].violations += violationCount; // Don't accumulate filesAnalyzed for each batch - use actual unique file count if (!mergedResults.summary.engines[engineName].filesSet) { mergedResults.summary.engines[engineName].files = actualFilesCount; mergedResults.summary.engines[engineName].filesSet = true; } mergedResults.summary.engines[engineName].batches += 1; mergedResults.summary.totalViolations += violationCount; } // Update unique engine count and correct total files count mergedResults.summary.totalEngines = uniqueEngines.size; mergedResults.summary.totalFiles = actualFilesCount; return mergedResults; } /** * Create a visual progress bar * @param {number} percent - Progress percentage (0-100) * @returns {string} Progress bar string */ createProgressBar(percent) { const width = 20; const filled = Math.round((percent / 100) * width); const empty = width - filled; return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'; } /** * Get human-readable display name for an engine * Following Rule C006: Verb-noun naming * @param {string} engineName - Engine identifier * @returns {string} Display name for the engine */ getEngineDisplayName(engineName) { const displayNames = { 'heuristic': 'Heuristic Engine', 'eslint': 'ESLint Engine', 'openai': 'AI Engine', 'ai': 'AI Engine', 'typescript': 'TypeScript Engine', 'semantic': 'Semantic Engine' }; return displayNames[engineName] || `${engineName.charAt(0).toUpperCase()}${engineName.slice(1)} Engine`; } /** * Format batch rules display with IDs and names * Following Rule C006: Verb-noun naming * @param {Object[]} batch - Array of rule objects * @returns {string} Formatted rules display string */ formatBatchRulesDisplay(batch) { const maxLength = 60; const ruleDisplays = batch.map(rule => { const id = rule.id || rule; const name = rule.name || rule.title; if (name) { // Truncate long rule names const shortName = name.length > 25 ? name.substring(0, 25) + '…' : name; return `${id}: ${shortName}`; } return id; }); // Join and truncate if too long const fullDisplay = ruleDisplays.join(' │ '); if (fullDisplay.length > maxLength) { return fullDisplay.substring(0, maxLength) + '…'; } return fullDisplay; } /** * Display batch rules in multi-line format for easier debugging * Following Rule C006: Verb-noun naming * @param {Object[]} batch - Array of rule objects */ displayBatchRulesMultiLine(batch) { for (let i = 0; i < batch.length; i++) { const rule = batch[i]; const id = rule.id || rule; const name = rule.name || rule.title || ''; const isLast = i === batch.length - 1; const prefix = isLast ? ' │ └─' : ' │ ├─'; if (name) { console.log(chalk.gray(prefix) + ' ' + chalk.cyan(id) + chalk.gray(': ') + chalk.white(name)); } else { console.log(chalk.gray(prefix) + ' ' + chalk.cyan(id)); } } } /** * Group rules by category prefix (C, S, T, R, etc.) * Following Rule C006: Verb-noun naming * @param {Object[]} rules - Rules to group * @returns {Object} Object with category arrays */ groupRulesByCategory(rules) { const groups = {}; for (const rule of rules) { const ruleId = rule.id || rule; const category = ruleId.charAt(0); // Get first character (C, S, T, R, etc.) if (!groups[category]) { groups[category] = []; } groups[category].push(rule); } return groups; } /** * Count violations in engine results * Following Rule C006: Verb-noun naming * @param {Object} engineResult - Result from an engine * @returns {number} Number of violations */ countViolations(engineResult) { if (!engineResult.results) return 0; return engineResult.results.reduce((total, fileResult) => { return total + (fileResult.violations?.length || 0); }, 0); } /** * Get information about registered engines * Following Rule C006: Verb-noun naming * @returns {Object} Engine information */ getEngineInfo() { const engines = {}; for (const [name, engine] of this.engines) { engines[name] = engine.getEngineInfo(); } return engines; } /** * Get available engines * Following Rule C006: Verb-noun naming * @returns {string[]} Array of available engine IDs */ getAvailableEngines() { return Array.from(this.engines.keys()); } /** * Analyze files with rules (main analysis interface) * Following Rule C006: Verb-noun naming * @param {string[]} files - Files to analyze * @param {Object[]} rules - Rules to apply * @param {Object} options - Analysis options * @returns {Promise<Object>} Analysis results */ async analyze(files, rules, options = {}) { // Ensure files are in options for compatibility const analysisOptions = { ...options, files }; // Ensure verbose/quiet flags are available in config const config = { ...options.config || {}, verbose: options.verbose || options.config?.verbose, quiet: options.quiet || options.config?.quiet }; return await this.runAnalysis(rules, analysisOptions, config); } /** * Cleanup all engines and performance optimizer * Following Rule C006: Verb-noun naming * @returns {Promise<void>} */ async cleanup() { for (const engine of this.engines.values()) { try { await engine.cleanup(); } catch (error) { console.warn(chalk.yellow(`⚠️ Failed to cleanup engine ${engine.id}:`), error.message); } } // Cleanup performance optimizer try { await this.performanceOptimizer.cleanup(); } catch (error) { console.warn(chalk.yellow(`⚠️ Failed to cleanup performance optimizer:`), error.message); } this.initialized = false; } } module.exports = AnalysisOrchestrator;