UNPKG

@felixgeelhaar/cclint

Version:

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

268 lines 13.1 kB
import { Command } from 'commander'; import { FileReader } from '../../infrastructure/FileReader.js'; import { RulesEngine } from '../../domain/RulesEngine.js'; import { buildSeverityOverrides, } from '../../domain/Config.js'; import { createRules } from '../../rules/registry/createRules.js'; import { formatResult } from '../formatters/textFormatter.js'; import { formatDirectoryResult } from '../formatters/directoryFormatter.js'; import { ConfigLoader } from '../../infrastructure/ConfigLoader.js'; import { AutoFixer } from '../../infrastructure/AutoFixer.js'; import { InteractiveFixer } from '../../infrastructure/InteractiveFixer.js'; import { RuleRegistry } from '../../infrastructure/RuleRegistry.js'; import { PluginLoader } from '../../infrastructure/PluginLoader.js'; import { FileDiscovery } from '../../infrastructure/FileDiscovery.js'; import { existsSync, statSync, writeFileSync } from 'fs'; import { GitDiffProvider } from '../../infrastructure/GitDiffProvider.js'; import { LintingResult } from '../../domain/LintingResult.js'; export const lintEnhancedCommand = new Command('lint') .description('Lint a CLAUDE.md file, or a project directory of config files') .argument('<path>', 'Path to a config file, or a directory to lint project-wide') .option('-f, --format <format>', 'Output format (text, json, sarif)', 'text') .option('--max-size <size>', 'Maximum file size in characters', '10000') .option('-c, --config <path>', 'Path to configuration file') .option('--fix', 'Automatically fix problems where possible') .option('-i, --interactive', 'Interactively fix problems one at a time') .option('--diff', 'Only show violations on changed lines') .option('--diff-ref <ref>', 'Git ref to compare against (default: HEAD)', 'HEAD') .option('--plain', 'Plain text output (no emoji) for CI logs and screen readers') .option('--summary', 'Group violations by rule with counts (compact view for noisy files)') .option('--allow-plugins', 'Load custom rule plugins declared in project config. Off by default: ' + 'config-declared plugins execute code in-process, so loading them is ' + 'opt-in (or set CCLINT_ALLOW_PLUGINS=1).') .action(async (file, options) => { try { // Load configuration const config = ConfigLoader.load(options.config); // Initialize plugin system const registry = new RuleRegistry(); const pluginLoader = new PluginLoader(registry); // Load plugins if configured. // // SECURITY: plugins declared in a project's config execute arbitrary // code in-process. They are only loaded when the operator opts in // out-of-band via `--allow-plugins` or `CCLINT_ALLOW_PLUGINS=1` — a // gate the linted repository itself cannot set. Otherwise the plugins // are skipped (never imported) and the user is told how to enable them. if (config.plugins && config.plugins.length > 0) { const allowPlugins = options.allowPlugins === true || process.env['CCLINT_ALLOW_PLUGINS'] === '1'; const pluginResult = await pluginLoader.loadPluginsFromConfig(config.plugins, { allowPlugins }); if (pluginResult.loaded.length > 0) { console.log(`📦 Loaded ${pluginResult.loaded.length} plugin(s): ${pluginResult.loaded.join(', ')}`); } if (pluginResult.failed.length > 0) { console.warn(`⚠️ Failed to load ${pluginResult.failed.length} plugin(s):`); pluginResult.failed.forEach(failure => { console.warn(` - ${failure.name}: ${failure.error.message}`); }); } if (pluginResult.skipped.length > 0) { console.warn(`🔒 Skipped ${pluginResult.skipped.length} config-declared plugin(s) for security: ${pluginResult.skipped.join(', ')}`); console.warn(' Plugins run arbitrary code in-process. If you trust this ' + "project's plugins, re-run with --allow-plugins (or set " + 'CCLINT_ALLOW_PLUGINS=1) to enable them.'); } } const fileReader = new FileReader(); const maxSize = parseInt(options.maxSize, 10); if (isNaN(maxSize) || maxSize <= 0) { console.error('Error: --max-size must be a positive number'); process.exit(1); } // Build the built-in rules from the single canonical factory. The CLI // `--max-size` flag still takes precedence over config when the user // set it explicitly (i.e. it differs from the default). const effectiveConfig = options.maxSize !== '10000' ? { ...config, rules: { ...config.rules, 'file-size': { ...config.rules['file-size'], enabled: config.rules['file-size']?.enabled ?? false, options: { ...config.rules['file-size']?.options, maxSize, }, }, }, } : config; const rules = createRules(effectiveConfig); // Add custom rules from plugins const allCustomRules = registry.getAllRules(); const enabledCustomRules = []; for (const rule of allCustomRules) { // Only CustomRule instances have generateFixes method if ('generateFixes' in rule && typeof rule.generateFixes === 'function') { const customRule = rule; // Check if the custom rule is enabled in configuration const ruleConfig = config.rules[customRule.id]; if (ruleConfig?.enabled !== false) { rules.push(customRule); enabledCustomRules.push(customRule); } } } const engine = new RulesEngine(rules, buildSeverityOverrides(config)); // Project-wide lint: when the target is a directory, discover every // Claude Code config file and lint each through the SAME rule pipeline. // Single-file behavior below is left entirely unchanged. if (isDirectoryTarget(file)) { await lintDirectory(file, engine, fileReader, { format: options.format, plain: options.plain, summary: options.summary, }); return; } const contextFile = await fileReader.readContextFile(file); let result = engine.lint(contextFile); // Filter violations to changed lines if --diff is enabled if (options.diff) { const diffProvider = new GitDiffProvider(); if (diffProvider.isGitRepository()) { const diffOptions = options.diffRef ? { ref: options.diffRef } : {}; const diffInfo = diffProvider.getFileDiffInfo(file, diffOptions); if (!diffInfo.isNew) { // Filter violations to only those on changed lines const filteredViolations = [...result.violations].filter(violation => diffProvider.isLineChanged(violation.location.line, diffInfo)); // Create a new result with filtered violations const filteredResult = new LintingResult(contextFile); filteredViolations.forEach(v => filteredResult.addViolation(v)); result = filteredResult; if (filteredViolations.length === 0) { console.log(`✅ No violations found on changed lines (comparing to ${options.diffRef ?? 'HEAD'})`); process.exit(0); } console.log(`📝 Showing ${filteredViolations.length} violation(s) on changed lines (comparing to ${options.diffRef ?? 'HEAD'})\n`); } } else { console.warn('⚠️ Not a git repository. Running lint without diff filtering.\n'); } } // Interactive fix if requested if (options.interactive) { const interactiveFixer = new InteractiveFixer(); const fixResult = await interactiveFixer.fix(contextFile.content, [...result.violations], enabledCustomRules); if (fixResult.fixed) { writeFileSync(file, fixResult.content, 'utf8'); // Re-lint after fixes const newContextFile = await fileReader.readContextFile(file); const newResult = engine.lint(newContextFile); const output = formatResult(newResult, options.format, { plain: options.plain, }); console.log(output); if (newResult.getErrorCount() > 0) { process.exit(1); } process.exit(0); } } // Auto-fix if requested if (options.fix) { const fixes = AutoFixer.generateFixesForViolations([...result.violations], contextFile.content, enabledCustomRules); if (fixes.length > 0) { const fixResult = AutoFixer.applyFixes(contextFile.content, fixes); if (fixResult.fixed) { writeFileSync(file, fixResult.content, 'utf8'); console.log(`🔧 Applied ${fixResult.appliedFixes.length} fixes to ${file}`); // Re-lint after fixes const newContextFile = await fileReader.readContextFile(file); const newResult = engine.lint(newContextFile); const output = formatResult(newResult, options.format, { plain: options.plain, }); console.log(output); if (newResult.getErrorCount() > 0) { process.exit(1); } process.exit(0); } } } const fixableFixes = AutoFixer.generateFixesForViolations([...result.violations], contextFile.content, enabledCustomRules); const fixableCount = new Set(fixableFixes.map(f => `${f.range.start.line}:${f.range.start.column}`)).size; const output = formatResult(result, options.format, { plain: options.plain, summary: options.summary, fixableCount, }); console.log(output); if (result.getErrorCount() > 0) { process.exit(1); } else if (result.getWarningCount() > 0) { process.exit(0); } process.exit(0); } catch (error) { if (error instanceof Error) { console.error(`Error: ${error.message}`); } else { console.error('Error: Unknown error occurred'); } process.exit(1); } }); /** * Whether a target path is an existing directory (as opposed to a file). * * @remarks * Missing or unreadable paths report `false` so the caller falls through to the * single-file flow, where {@link FileReader} raises a precise, user-facing error. */ function isDirectoryTarget(target) { try { return existsSync(target) && statSync(target).isDirectory(); } catch { return false; } } /** * Discover and lint every Claude Code config file under a project directory, * then report aggregated results and exit with a non-zero code if ANY file has * an error-severity violation. * * @remarks * Reuses the exact `RulesEngine` built for single-file linting; each file is * read via {@link FileReader} and only receives the rules that self-gate to its * kind (via `Rule.appliesTo`). An unreadable individual file is reported and * skipped rather than aborting the whole run. */ async function lintDirectory(dir, engine, fileReader, options) { const files = new FileDiscovery().discover(dir); if (files.length === 0) { console.log(`No Claude Code config files found in ${dir}. Nothing to lint.`); process.exit(0); } const results = []; for (const filePath of files) { try { const contextFile = await fileReader.readContextFile(filePath); results.push(engine.lint(contextFile)); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.warn(`⚠️ Skipped ${filePath}: ${message}`); } } if (results.length === 0) { console.log(`No lintable config files found in ${dir}.`); process.exit(0); } const output = formatDirectoryResult(results, options.format, { plain: options.plain, summary: options.summary, }); console.log(output); const hasError = results.some(result => result.getErrorCount() > 0); process.exit(hasError ? 1 : 0); } //# sourceMappingURL=lintEnhanced.js.map