UNPKG

@sun-asterisk/sunlint

Version:

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

617 lines (538 loc) 18.4 kB
const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); const os = require('os'); const { minimatch } = require('minimatch'); // Rule C014: Dependency injection instead of direct instantiation const ConfigSourceLoader = require('./config-source-loader'); const ConfigPresetResolver = require('./config-preset-resolver'); const ConfigMerger = require('./config-merger'); const ConfigValidator = require('./config-validator'); const ConfigOverrideProcessor = require('./config-override-processor'); const SunlintRuleAdapter = require('./adapters/sunlint-rule-adapter'); /** * Main configuration manager - orchestrates config loading process * Rule C005: Single responsibility - orchestrates other config services * Rule C015: Domain language - ConfigManager as main coordinator * Rule C014: Uses dependency injection for all services * REFACTORED: Now uses SunlintRuleAdapter instead of direct registry access */ class ConfigManager { constructor() { // Rule C014: Dependency injection this.sourceLoader = new ConfigSourceLoader(); this.presetResolver = new ConfigPresetResolver(); this.merger = new ConfigMerger(); this.validator = new ConfigValidator(); this.overrideProcessor = new ConfigOverrideProcessor(); this.ruleAdapter = SunlintRuleAdapter.getInstance(); this.initialized = false; this.defaultConfig = { rules: {}, categories: {}, // Enhanced language-specific configuration languages: { typescript: { include: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], exclude: ['**/*.d.ts', '**/*.test.ts', '**/*.spec.ts'], parser: 'typescript' }, javascript: { include: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], exclude: ['**/*.min.js', '**/*.bundle.js'], parser: 'espree' }, dart: { include: ['**/*.dart'], exclude: ['**/*.g.dart', '**/*.freezed.dart', '**/*.mocks.dart'], parser: 'dart' }, kotlin: { include: ['**/*.kt', '**/*.kts'], exclude: ['**/build/**', '**/generated/**'], parser: 'kotlin' } }, // Global file patterns (cross-language) include: [ 'src/**', 'lib/**', 'app/**', 'packages/**' ], exclude: [ 'node_modules/**', 'dist/**', 'build/**', 'coverage/**', '.git/**', '**/*.min.*', '**/*.bundle.*', '**/generated/**', '**/*.generated.*', '.next/**', '.nuxt/**', 'vendor/**' ], // Test file patterns with specific rules testPatterns: { include: ['**/*.test.*', '**/*.spec.*', '**/test/**', '**/tests/**', '**/__tests__/**'], rules: { 'C006': 'off', // Function naming less strict in tests 'C019': 'warn' // Log level still important in tests } }, // Rule-specific overrides for different contexts overrides: [ { files: ['**/*.d.ts'], rules: { 'C006': 'off', 'C007': 'off' } }, { files: ['**/migrations/**', '**/seeds/**'], rules: { 'C031': 'off' // Validation separation not needed in migrations } }, { files: ['**/config/**', '**/*.config.*'], rules: { 'C006': 'warn', // Config files may have different naming 'C015': 'off' // Domain language not strict in config } } ], env: {}, parserOptions: {}, // ESLint Integration Configuration eslintIntegration: { enabled: false, mergeRules: true, preserveUserConfig: true, runAfterSunLint: false }, output: { format: 'eslint', console: true, summary: true }, ai: { enabled: false, fallbackToPattern: true, provider: 'openai', model: 'gpt-4o-mini' }, performance: { maxConcurrentRules: 5, timeoutMs: 30000, cacheEnabled: true, cacheLocation: '.sunlint-cache/' }, reporting: { includeContext: true, showFixSuggestions: true, groupByFile: true, sortBy: 'severity', showProgress: true, exitOnError: false } }; } /** * Rule C006: loadConfiguration - verb-noun naming * Rule C005: Single responsibility - orchestrates config loading * Rule C012: Command method - loads and returns config * REFACTORED: Now initializes rule adapter */ async loadConfiguration(configPath, cliOptions = {}) { // Initialize rule adapter if (!this.initialized) { await this.ruleAdapter.initialize(); this.initialized = true; } // 1. Start with built-in defaults let config = { ...this.defaultConfig }; // 2. Environment variables config = this.merger.applyEnvironmentVariables(config); // 3. Global config (~/.sunlint.json) const globalConfig = this.sourceLoader.loadGlobalConfiguration(os.homedir(), cliOptions.verbose); if (globalConfig) { config = this.merger.mergeConfigurations(config, globalConfig.config); } // 4. Auto-discover project config if not explicitly provided let resolvedConfigPath = configPath; if (!configPath) { // Only auto-discover if no config path was provided const discoveredConfig = this.findConfigFile(cliOptions.input || process.cwd()); if (discoveredConfig) { resolvedConfigPath = discoveredConfig; if (cliOptions.verbose) { console.log(chalk.gray(`🔍 Auto-discovered config: ${discoveredConfig}`)); } } } else { // Use the explicitly provided config path if (cliOptions.verbose) { console.log(chalk.gray(`📄 Using explicit config: ${configPath}`)); } } // 5. Load project config (explicit or discovered) let projectConfig = null; if (resolvedConfigPath && fs.existsSync(resolvedConfigPath)) { if (resolvedConfigPath.endsWith('package.json')) { // Load from package.json sunlint field const pkg = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf8')); if (pkg.sunlint) { projectConfig = { config: pkg.sunlint, path: resolvedConfigPath, dir: path.dirname(resolvedConfigPath) }; } } else { // Load from dedicated config file projectConfig = this.sourceLoader.loadSpecificConfigFile(resolvedConfigPath, cliOptions.verbose); } if (projectConfig) { config = this.merger.mergeConfigurations(config, projectConfig.config); if (cliOptions.verbose) { console.log(chalk.gray(`📄 Loaded project config: ${projectConfig.path}`)); } } } // 6. Load ignore patterns (.sunlintignore) and merge into exclude const ignorePatterns = this.sourceLoader.loadIgnorePatterns( projectConfig?.dir || process.cwd(), cliOptions.verbose ); if (ignorePatterns.length > 0) { config.exclude = [...new Set([...(config.exclude || []), ...ignorePatterns])]; } // 7. Process any deprecated ignorePatterns in config config = this.merger.processIgnorePatterns(config); // 8. Apply CLI overrides (highest priority) config = this.merger.applyCLIOverrides(config, cliOptions); // 9. Resolve extends config = await this.resolveExtends(config); // 10. Validate config this.validator.validateConfiguration(config); // 11. Add metadata for enhanced file targeting const analysisScope = this.determineAnalysisScope(cliOptions.input); config._metadata = { analysisScope: analysisScope, shouldBypassProjectDiscovery: this.shouldBypassProjectDiscovery(analysisScope, cliOptions), targetInput: cliOptions.input, hasCliRules: this.hasRuleConfigInCLI(cliOptions) }; if (cliOptions.verbose) { console.log(chalk.gray(`📋 Enhanced Config: Scope=${analysisScope}, Bypass=${config._metadata.shouldBypassProjectDiscovery}`)); } return config; } mergeConfigs(base, override) { const merged = { ...base }; for (const [key, value] of Object.entries(override)) { if (key === 'rules' && typeof value === 'object') { merged.rules = { ...merged.rules, ...value }; } else if (key === 'categories' && typeof value === 'object') { merged.categories = { ...merged.categories, ...value }; } else if (typeof value === 'object' && !Array.isArray(value)) { merged[key] = { ...merged[key], ...value }; } else { merged[key] = value; } } return merged; } applyCLIOverrides(config, options) { const overrides = { ...config }; // Languages override if (options.languages) { overrides.languages = options.languages.split(',').map(l => l.trim()); } // Output format override if (options.format) { overrides.output = { ...overrides.output, format: options.format }; } // AI override if (options.ai === true) { overrides.ai = { ...overrides.ai, enabled: true }; } if (options.ai === false) { overrides.ai = { ...overrides.ai, enabled: false }; } // Performance overrides if (options.maxConcurrent) { overrides.performance = { ...overrides.performance, maxConcurrentRules: parseInt(options.maxConcurrent) }; } if (options.timeout) { overrides.performance = { ...overrides.performance, timeoutMs: parseInt(options.timeout) }; } // Cache override if (options.cache === false) { overrides.performance = { ...overrides.performance, cacheEnabled: false }; } return overrides; } /** * Rule C006: resolveExtends - verb-noun naming * Rule C005: Single responsibility - only handles extends resolution */ async resolveExtends(config) { if (!config.extends) { return config; } const extends_ = Array.isArray(config.extends) ? config.extends : [config.extends]; let resolvedConfig = { ...config }; for (const extendPath of extends_) { try { // Check if it's a preset if (extendPath.startsWith('@sun/sunlint/')) { const presetConfig = await this.presetResolver.loadPresetConfiguration(extendPath); resolvedConfig = this.merger.mergeConfigurations(presetConfig, resolvedConfig); } else { const extendedConfig = await this.loadExtendedConfig(extendPath); resolvedConfig = this.merger.mergeConfigurations(extendedConfig, resolvedConfig); } } catch (error) { console.error(chalk.yellow(`⚠️ Failed to extend config '${extendPath}':`), error.message); } } // Remove extends to avoid circular references delete resolvedConfig.extends; return resolvedConfig; } /** * Rule C006: loadExtendedConfig - verb-noun naming * REFACTORED: Now loads presets directly instead of through registry */ async loadExtendedConfig(extendPath) { if (extendPath.startsWith('@sun/sunlint/')) { // Load preset directly from preset file const presetName = extendPath.replace('@sun/sunlint/', ''); const presetPath = path.join(__dirname, '../config/presets', `${presetName}.json`); if (fs.existsSync(presetPath)) { return JSON.parse(fs.readFileSync(presetPath, 'utf8')); } else { throw new Error(`Preset not found: ${extendPath}`); } } else { // Load from file path const configPath = path.resolve(extendPath); if (fs.existsSync(configPath)) { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } else { throw new Error(`Config file not found: ${configPath}`); } } } /** * Rule C006: applyFileOverrides - verb-noun naming * Rule C014: Delegate to override processor */ applyFileOverrides(config, filePath) { return this.overrideProcessor.applyFileOverrides(config, filePath); } /** * Rule C006: getEffectiveRuleConfiguration - verb-noun naming * Rule C014: Delegate to validator * REFACTORED: Now uses rule adapter for rule validation */ getEffectiveRuleConfiguration(ruleId, config) { // Validate rule exists via adapter const rule = this.ruleAdapter.getRuleById(ruleId); if (!rule) { console.warn(`⚠️ Rule ${ruleId} not found in registry`); return null; } return this.validator.getEffectiveRuleConfiguration(ruleId, config, { rules: { [ruleId]: rule } }); } /** * Rule C006: normalizeRuleValue - verb-noun naming * Rule C014: Delegate to validator */ normalizeRuleValue(value) { return this.validator.normalizeRuleValue(value); } /** * Find configuration file using discovery hierarchy * Following Rule C005: Single responsibility - only handle config discovery * @param {string} startPath - Starting directory for config search * @returns {string|null} Path to config file or null if not found */ findConfigFile(startPath = process.cwd()) { const configNames = [ '.sunlint.json', '.sunlint.js', 'sunlint.config.js', 'sunlint.config.json' ]; let currentPath = path.resolve(startPath); // Traverse up directory tree while (currentPath !== path.dirname(currentPath)) { for (const configName of configNames) { const configPath = path.join(currentPath, configName); if (fs.existsSync(configPath)) { return configPath; } } currentPath = path.dirname(currentPath); } // Check for package.json with sunlint field const packageConfigPath = this.findPackageConfig(startPath); if (packageConfigPath) { return packageConfigPath; } return null; } /** * Find package.json with sunlint configuration * @param {string} startPath - Starting directory * @returns {string|null} Path to package.json or null */ findPackageConfig(startPath = process.cwd()) { let currentPath = path.resolve(startPath); while (currentPath !== path.dirname(currentPath)) { const packagePath = path.join(currentPath, 'package.json'); if (fs.existsSync(packagePath)) { try { const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); if (pkg.sunlint) { return packagePath; } } catch (error) { // Continue searching if package.json is invalid } } currentPath = path.dirname(currentPath); } return null; } /** * Find project root directory (where package.json exists) * @param {string} startPath - Starting directory * @returns {string} Project root path or startPath if not found */ findProjectRoot(startPath = process.cwd()) { let currentPath = path.resolve(startPath); while (currentPath !== path.dirname(currentPath)) { const packagePath = path.join(currentPath, 'package.json'); if (fs.existsSync(packagePath)) { return currentPath; } currentPath = path.dirname(currentPath); } return startPath; } // Legacy method names for backward compatibility // Rule C006: Maintaining existing API while delegating to new services async loadConfig(configPath, cliOptions) { return this.loadConfiguration(configPath, cliOptions); } mergeConfigs(base, override) { return this.merger.mergeConfigurations(base, override); } applyCLIOverrides(config, options) { return this.merger.applyCLIOverrides(config, options); } applyOverrides(config, filePath) { return this.overrideProcessor.applyFileOverrides(config, filePath); } validateConfig(config) { return this.validator.validateConfiguration(config); } /** * ENHANCED CONFIG STRATEGY METHODS * ================================ */ /** * Determine if CLI has rule configuration */ hasRuleConfigInCLI(cliOptions) { return !!( cliOptions.rule || cliOptions.rules || cliOptions.all || cliOptions.quality || cliOptions.security || cliOptions.category ); } /** * Determine analysis scope based on input */ determineAnalysisScope(inputPath) { if (!inputPath) return 'project'; const resolvedPath = path.resolve(inputPath); if (!fs.existsSync(resolvedPath)) { return 'project'; // Fallback for non-existent paths } const stat = fs.statSync(resolvedPath); if (stat.isFile()) { return 'file'; } else if (stat.isDirectory()) { // More specific logic for directory scope const currentDir = process.cwd(); // If input is current directory, it's project scope if (path.resolve(inputPath) === currentDir) { return 'project'; } // If input is a project root (has project markers), it's project scope if (this.isProjectRoot(resolvedPath)) { return 'project'; } // Otherwise it's folder scope return 'folder'; } return 'project'; } /** * Check if directory is a project root */ isProjectRoot(dirPath) { const projectMarkers = [ 'package.json', 'pubspec.yaml', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'Cargo.toml', 'go.mod', '.git', 'tsconfig.json', 'angular.json', 'next.config.js', 'nuxt.config.js' ]; return projectMarkers.some(marker => fs.existsSync(path.join(dirPath, marker)) ); } /** * Determine if project discovery should be bypassed for performance */ shouldBypassProjectDiscovery(analysisScope, cliOptions) { // Single file input - always bypass if (analysisScope === 'file') { return true; } // Folder scope (not project root) and has CLI rules - target folder only if (analysisScope === 'folder' && this.hasRuleConfigInCLI(cliOptions)) { return true; } return false; // Use project-wide discovery } } module.exports = ConfigManager;