UNPKG

@felixgeelhaar/cclint

Version:

Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.

132 lines 4.99 kB
import { readFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { defaultConfig } from '../domain/Config.js'; import { getPreset } from '../domain/presets.js'; export class ConfigLoader { static CONFIG_FILES = [ '.cclintrc.json', '.cclintrc.js', 'cclint.config.js', 'package.json', ]; static load(startDir = process.cwd()) { const configPath = this.findConfigFile(startDir); if (!configPath) { return defaultConfig; } try { const config = this.loadConfigFile(configPath); return this.resolveConfig(config); } catch (error) { console.warn(`Warning: Failed to load config from ${configPath}:`, error instanceof Error ? error.message : error); return defaultConfig; } } static findConfigFile(startDir) { let currentDir = startDir; while (currentDir !== dirname(currentDir)) { for (const configFile of this.CONFIG_FILES) { const configPath = join(currentDir, configFile); if (existsSync(configPath)) { return configPath; } } currentDir = dirname(currentDir); } return null; } static loadConfigFile(configPath) { if (configPath.endsWith('package.json')) { const packageJson = JSON.parse(readFileSync(configPath, 'utf8')); return packageJson['cclint'] ?? {}; } if (configPath.endsWith('.json')) { return JSON.parse(readFileSync(configPath, 'utf8')); } // For .js files, we'd need dynamic import, but keeping it simple for now throw new Error('JavaScript config files not yet supported'); } /** * Resolve a user config into a fully merged {@link CclintConfig}. * * @remarks * Layering is defaults ← preset(s) in `extends` order ← user config, so the * user's own settings always win and presets closer to the end of an * `extends` array override earlier ones. `rules` are deep-merged per rule * (enabled/severity/options); other fields (`ignore`, `plugins`) are * replaced by the more specific layer. `extends` is consumed here and never * appears in the resolved result. */ static resolveConfig(userConfig) { let resolved = defaultConfig; for (const preset of this.resolveExtends(userConfig.extends)) { resolved = this.mergeConfigs(resolved, preset); } resolved = this.mergeConfigs(resolved, userConfig); // `extends` is a resolution-time directive, not runtime config: it has been // consumed above, so it must not leak into the merged result. `resolved` is // always a fresh object from mergeConfigs, so deleting here is safe. delete resolved.extends; return resolved; } /** * Turn an `extends` value into the ordered list of preset configs to apply. * Only built-in named presets are supported; an unknown name is warned about * and skipped so a typo degrades gracefully rather than aborting the lint. */ static resolveExtends(ext) { if (!ext) { return []; } const names = Array.isArray(ext) ? ext : [ext]; const presets = []; for (const name of names) { const preset = getPreset(name); if (!preset) { console.warn(`Warning: Unknown preset "${name}" in "extends". Ignoring.`); continue; } presets.push(preset); } return presets; } /** Merge an override layer over a base config; the override always wins. */ static mergeConfigs(base, override) { return { ...base, ...override, rules: this.mergeRules(base.rules, override.rules), }; } /** * Deep-merge two rule maps. For each rule present in either layer, the * override's fields win, and `options` are themselves shallow-merged so a * layer can tweak a single option without dropping the others. */ static mergeRules(base, override) { if (!override) { return { ...base }; } const merged = { ...base }; for (const [ruleId, overrideRule] of Object.entries(override)) { if (!overrideRule) { continue; } const baseRule = base[ruleId]; const mergedRule = { ...baseRule, ...overrideRule, }; if (baseRule?.options || overrideRule.options) { mergedRule.options = { ...(baseRule?.options ?? {}), ...(overrideRule.options ?? {}), }; } merged[ruleId] = mergedRule; } return merged; } } //# sourceMappingURL=ConfigLoader.js.map