@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
JavaScript
/**
* 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;