UNPKG

@sun-asterisk/sunlint

Version:

â˜€ī¸ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

659 lines (576 loc) â€ĸ 18.4 kB
/** * Dart Analyzer * Implements ILanguageAnalyzer for Dart files * Communicates with embedded Dart binary via JSON-RPC over STDIO * * Following Rule C005: Single responsibility - Dart analysis only * Following Rule C014: Dependency injection - implements ILanguageAnalyzer */ const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const { EventEmitter } = require('events'); const { ILanguageAnalyzer } = require('../interfaces/language-analyzer.interface'); /** * JSON-RPC Client for Dart Analyzer subprocess */ class DartAnalyzerClient extends EventEmitter { constructor(options = {}) { super(); this.process = null; this.requestId = 0; this.pendingRequests = new Map(); this.buffer = ''; this.timeout = options.timeout || 30000; this.verbose = options.verbose || false; } /** * Start the Dart analyzer subprocess * @param {string} binaryPath - Path to the Dart analyzer binary * @returns {Promise<boolean>} */ async start(binaryPath) { return new Promise((resolve, reject) => { try { if (this.verbose) { console.log(`đŸŽ¯ Starting Dart analyzer: ${binaryPath}`); } this.process = spawn(binaryPath, [], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }); // Handle stdout (JSON-RPC responses) this.process.stdout.on('data', (data) => { this.handleData(data.toString()); }); // Handle stderr (debug output) this.process.stderr.on('data', (data) => { if (this.verbose) { console.log(`[Dart Analyzer] ${data.toString().trim()}`); } }); // Handle process errors this.process.on('error', (error) => { console.error(`❌ Dart analyzer process error:`, error.message); this.emit('error', error); reject(error); }); // Handle process exit this.process.on('exit', (code, signal) => { if (code !== 0 && this.verbose) { console.warn(`âš ī¸ Dart analyzer exited with code ${code}, signal ${signal}`); } this.emit('exit', code, signal); }); // Wait a bit for the process to start setTimeout(() => resolve(true), 100); } catch (error) { reject(error); } }); } /** * Handle incoming data from the subprocess * @param {string} data - Raw data from stdout */ handleData(data) { this.buffer += data; // Process complete lines (newline-delimited JSON) const lines = this.buffer.split('\n'); this.buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (!line.trim()) continue; try { const response = JSON.parse(line); this.handleResponse(response); } catch (error) { if (this.verbose) { console.warn(`âš ī¸ Invalid JSON from Dart analyzer:`, line.substring(0, 100)); } } } } /** * Handle a JSON-RPC response * @param {Object} response - Parsed JSON-RPC response */ handleResponse(response) { if (response.id !== undefined) { const pending = this.pendingRequests.get(response.id); if (pending) { this.pendingRequests.delete(response.id); clearTimeout(pending.timeout); if (response.error) { pending.reject(new Error(response.error.message || 'Unknown error')); } else { pending.resolve(response.result); } } } else if (response.method) { // Notification from server this.emit('notification', response.method, response.params); } } /** * Send a JSON-RPC request * @param {string} method - RPC method name * @param {Object} params - Method parameters * @returns {Promise<Object>} - Response result */ async sendRequest(method, params = {}) { return new Promise((resolve, reject) => { if (!this.process) { reject(new Error('Dart analyzer not started')); return; } const id = ++this.requestId; const request = { jsonrpc: '2.0', id, method, params }; // Set up timeout const timeoutId = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Request ${method} timed out after ${this.timeout}ms`)); }, this.timeout); // Store pending request this.pendingRequests.set(id, { resolve, reject, timeout: timeoutId }); // Send request try { this.process.stdin.write(JSON.stringify(request) + '\n'); } catch (error) { this.pendingRequests.delete(id); clearTimeout(timeoutId); reject(error); } }); } /** * Stop the subprocess */ async stop() { if (this.process) { // Cancel all pending requests for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Analyzer stopped')); } this.pendingRequests.clear(); // Kill the process this.process.kill('SIGTERM'); this.process = null; } } /** * Check if the client is connected * @returns {boolean} */ isConnected() { return this.process !== null && !this.process.killed; } } /** * Dart Language Analyzer */ class DartAnalyzer extends ILanguageAnalyzer { constructor() { super('dart', ['.dart']); this.client = null; this.binaryPath = null; this.projectPath = null; this.useFallback = false; // Use regex fallback if binary not available // Symbol table cache this.symbolTableCache = new Map(); } /** * Initialize the Dart analyzer * @param {Object} config - Configuration * @returns {Promise<boolean>} */ async initialize(config) { const { projectPath = process.cwd(), targetFiles = null, verbose = false } = config; this.projectPath = projectPath; this.verbose = verbose; // Filter only Dart files from targetFiles const dartFiles = targetFiles ? targetFiles.filter(f => this.supportsFile(f)) : []; if (dartFiles.length === 0 && targetFiles) { if (verbose) { console.log(`🔷 DartAnalyzer: No Dart files to analyze, skipping initialization`); } this.initialized = true; return true; } try { // Resolve the Dart analyzer binary this.binaryPath = await this.resolveBinary(); if (this.binaryPath) { // Initialize the JSON-RPC client this.client = new DartAnalyzerClient({ verbose, timeout: 30000 }); await this.client.start(this.binaryPath); // Send initialization request await this.client.sendRequest('initialize', { projectPath, targetFiles: dartFiles }); this.initialized = true; if (verbose) { console.log(`✅ DartAnalyzer initialized with binary: ${this.binaryPath}`); console.log(` 📄 Target files: ${dartFiles.length} Dart files`); } return true; } else { // Fall back to regex-based analysis if (verbose) { console.log(`â„šī¸ DartAnalyzer: Binary not found, using regex fallback mode`); } this.useFallback = true; this.initialized = true; return true; } } catch (error) { // Silent fallback - Dart analyzer not available // Fall back to regex mode this.useFallback = true; this.initialized = true; return false; } } /** * Resolve the path to the Dart analyzer binary * Priority: bundled > downloaded > pub global * @returns {Promise<string|null>} */ async resolveBinary() { const platform = process.platform; const binaryName = platform === 'win32' ? 'sunlint-dart-windows.exe' : `sunlint-dart-${platform === 'darwin' ? 'macos' : 'linux'}`; // Priority 1: Bundled binary in dart_analyzer/bin const bundledPath = path.join(__dirname, '../../dart_analyzer/bin', binaryName); if (fs.existsSync(bundledPath)) { // Ensure executable on Unix if (platform !== 'win32') { try { fs.chmodSync(bundledPath, '755'); } catch (e) { // Ignore chmod errors } } return bundledPath; } // Priority 2: Cached binary (downloaded on first use) const cachePath = path.join( process.env.HOME || process.env.USERPROFILE || '/tmp', '.sunlint', 'bin', binaryName ); if (fs.existsSync(cachePath)) { return cachePath; } // Priority 3: Check if Dart is available (pub global) if (await this.isDartAvailable()) { if (this.verbose) { console.log(`â„šī¸ Dart SDK detected, can use pub global analyzer`); } // For now, return null and use fallback // In future, could run 'dart pub global run sunlint_analyzer' return null; } // No binary available if (this.verbose) { console.log(`â„šī¸ Dart analyzer binary not found at:`); console.log(` - ${bundledPath}`); console.log(` - ${cachePath}`); } return null; } /** * Check if Dart SDK is available * @returns {Promise<boolean>} */ async isDartAvailable() { return new Promise((resolve) => { const dartProcess = spawn('dart', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], shell: true }); dartProcess.on('error', () => resolve(false)); dartProcess.on('exit', (code) => resolve(code === 0)); // Timeout after 2 seconds setTimeout(() => { dartProcess.kill(); resolve(false); }, 2000); }); } /** * Analyze a single Dart file * @param {string} filePath - Path to the file * @param {Object[]} rules - Rules to apply * @param {Object} [options] - Analysis options * @returns {Promise<Object[]>} - Array of violations */ async analyzeFile(filePath, rules, options = {}) { if (!this.initialized || !this.supportsFile(filePath)) { return []; } const startTime = Date.now(); let violations = []; try { if (this.useFallback) { // Use regex-based fallback analysis violations = await this.analyzeWithRegex(filePath, rules, options); } else if (this.client && this.client.isConnected()) { // Use the binary analyzer const result = await this.client.sendRequest('analyze', { filePath, rules: rules.map(r => ({ id: r.id || r.ruleId, config: r.config || {} })) }); violations = result.violations || []; } else { // Fallback if client disconnected violations = await this.analyzeWithRegex(filePath, rules, options); } this.stats.filesAnalyzed++; this.stats.totalAnalysisTime += Date.now() - startTime; } catch (error) { if (this.verbose) { console.warn(`âš ī¸ Error analyzing ${path.basename(filePath)}:`, error.message); } } return violations; } /** * Fallback regex-based analysis for Dart files * @param {string} filePath - Path to the file * @param {Object[]} rules - Rules to apply * @param {Object} [options] - Analysis options * @returns {Promise<Object[]>} */ async analyzeWithRegex(filePath, rules, options = {}) { const violations = []; try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); for (const rule of rules) { const ruleId = rule.id || rule.ruleId; // Basic pattern-based analysis const ruleViolations = this.applyPatternRule(filePath, content, lines, rule); violations.push(...ruleViolations); } } catch (error) { if (this.verbose) { console.warn(`âš ī¸ Regex analysis failed for ${path.basename(filePath)}:`, error.message); } } return violations; } /** * Apply a pattern-based rule to Dart content * @param {string} filePath - File path * @param {string} content - File content * @param {string[]} lines - Lines of content * @param {Object} rule - Rule definition * @returns {Object[]} */ applyPatternRule(filePath, content, lines, rule) { const violations = []; const ruleId = rule.id || rule.ruleId; // Common Dart patterns for basic analysis const patterns = this.getDartPatterns(ruleId); for (const pattern of patterns) { let match; while ((match = pattern.regex.exec(content)) !== null) { // Find line number const upToMatch = content.substring(0, match.index); const lineNumber = (upToMatch.match(/\n/g) || []).length + 1; const column = match.index - upToMatch.lastIndexOf('\n'); violations.push({ ruleId, filePath, line: lineNumber, column, message: pattern.message, severity: rule.severity || 'warning', analysisMethod: 'regex' }); } } return violations; } /** * Get Dart-specific patterns for a rule * @param {string} ruleId - Rule ID * @returns {Array<{regex: RegExp, message: string}>} */ getDartPatterns(ruleId) { // Common Dart anti-patterns by rule category const patterns = { // Complexity rules 'C001': [], // High complexity - requires AST 'C008': [], // Deep nesting - requires AST // Naming rules 'N001': [ { regex: /\bclass\s+[a-z][a-zA-Z0-9_]*/g, message: 'Class names should use UpperCamelCase' }, { regex: /\b(var|final|const)\s+[A-Z][a-zA-Z0-9_]*\s*=/g, message: 'Variable names should use lowerCamelCase' } ], // Error handling rules 'E001': [ { regex: /catch\s*\(\s*e\s*\)\s*\{\s*\}/g, message: 'Empty catch block - errors are silently ignored' }, { regex: /catch\s*\(\s*_\s*\)\s*\{/g, message: 'Ignoring caught exception' } ], // Security rules 'S003': [ { regex: /print\s*\(\s*['"]?(password|secret|token|key|api[_-]?key)/gi, message: 'Potential sensitive data in print statement' } ], 'S022': [ { regex: /\$\{[^}]*\}/g, message: 'String interpolation - check for XSS vulnerability if rendering HTML' } ] }; return patterns[ruleId] || []; } /** * Get the Symbol Table for a Dart file * @param {string} filePath - Path to the file * @returns {Promise<Object|null>} */ async getSymbolTable(filePath) { if (!this.initialized || !this.supportsFile(filePath)) { return null; } // Check cache const absolutePath = path.resolve(filePath); if (this.symbolTableCache.has(absolutePath)) { this.stats.cacheHits++; return this.symbolTableCache.get(absolutePath); } this.stats.cacheMisses++; try { if (this.client && this.client.isConnected()) { const result = await this.client.sendRequest('getSymbolTable', { filePath }); if (result) { this.symbolTableCache.set(absolutePath, result); } return result; } else { // Basic symbol table from regex parsing return await this.buildBasicSymbolTable(filePath); } } catch (error) { if (this.verbose) { console.warn(`âš ī¸ Failed to get symbol table for ${path.basename(filePath)}:`, error.message); } return null; } } /** * Build a basic symbol table using regex parsing * @param {string} filePath - Path to the file * @returns {Promise<Object|null>} */ async buildBasicSymbolTable(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const symbolTable = { filePath, fileName: path.basename(filePath), imports: [], exports: [], classes: [], functions: [], variables: [], lastModified: Date.now() }; // Extract imports const importRegex = /import\s+['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { const lineNumber = (content.substring(0, match.index).match(/\n/g) || []).length + 1; symbolTable.imports.push({ module: match[1], line: lineNumber }); } // Extract classes const classRegex = /class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+with\s+([\w,\s]+))?(?:\s+implements\s+([\w,\s]+))?\s*\{/g; while ((match = classRegex.exec(content)) !== null) { const lineNumber = (content.substring(0, match.index).match(/\n/g) || []).length + 1; symbolTable.classes.push({ name: match[1], extends: match[2] || null, with: match[3] ? match[3].split(',').map(s => s.trim()) : [], implements: match[4] ? match[4].split(',').map(s => s.trim()) : [], line: lineNumber }); } // Extract functions const functionRegex = /(?:Future|void|int|double|bool|String|dynamic|\w+)\s+(\w+)\s*\([^)]*\)\s*(?:async\s*)?\{/g; while ((match = functionRegex.exec(content)) !== null) { const lineNumber = (content.substring(0, match.index).match(/\n/g) || []).length + 1; symbolTable.functions.push({ name: match[1], line: lineNumber }); } this.symbolTableCache.set(path.resolve(filePath), symbolTable); return symbolTable; } catch (error) { return null; } } /** * Clear the symbol table cache */ clearCache() { this.symbolTableCache.clear(); } /** * Cleanup resources * @returns {Promise<void>} */ async dispose() { if (this.client) { await this.client.stop(); this.client = null; } this.symbolTableCache.clear(); this.initialized = false; if (this.verbose) { console.log(`🧹 DartAnalyzer disposed`); console.log(` 📊 Files analyzed: ${this.stats.filesAnalyzed}`); console.log(` đŸŽ¯ Cache hits: ${this.stats.cacheHits}`); console.log(` đŸ“Ļ Fallback mode: ${this.useFallback}`); } } } module.exports = DartAnalyzer;