UNPKG

@felixgeelhaar/cclint

Version:

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

131 lines 4.77 kB
import { readdirSync } from 'fs'; import { join } from 'path'; /** * Directory names that discovery never descends into. * * @remarks * These hold generated output, VCS metadata, or tooling scratch space — none of * which contain first-party Claude Code config worth linting, and some of which * (e.g. `node_modules`) are large enough to make an unfiltered walk slow. */ export const IGNORED_DIRECTORIES = new Set([ 'node_modules', '.git', 'dist', 'coverage', '.stryker-tmp', ]); const isMarkdown = (name) => /\.md$/i.test(name); const basenameOf = (segments) => segments[segments.length - 1] ?? ''; const parentDirOf = (segments) => segments[segments.length - 2]; /** * True when `first` then `second` appear as consecutive *ancestor* directories * of the file — the runtime equivalent of a `first/second/**` glob prefix. */ function hasAncestorDirPair(segments, first, second) { // Require at least [first, second, filename]; the pair must sit above the // file, so stop before the last (filename) segment. for (let i = 0; i + 2 < segments.length; i++) { if (segments[i] === first && segments[i + 1] === second) { return true; } } return false; } /** * The config files cclint understands, expressed against root-relative path * segments. Order is presentational only; a file matches if ANY pattern does. */ export const CONFIG_FILE_PATTERNS = [ { label: 'CLAUDE.md (including nested)', matches: s => basenameOf(s) === 'CLAUDE.md', }, { label: '.claude/skills/**/*.md', matches: s => isMarkdown(basenameOf(s)) && hasAncestorDirPair(s, '.claude', 'skills'), }, { label: '.claude/agents/**/*.md', matches: s => isMarkdown(basenameOf(s)) && hasAncestorDirPair(s, '.claude', 'agents'), }, { label: '.claude/output-styles/**/*.md', matches: s => isMarkdown(basenameOf(s)) && hasAncestorDirPair(s, '.claude', 'output-styles'), }, { label: '.claude/settings.json', matches: s => parentDirOf(s) === '.claude' && basenameOf(s) === 'settings.json', }, { label: '.claude/settings.local.json', matches: s => parentDirOf(s) === '.claude' && basenameOf(s) === 'settings.local.json', }, { label: '.mcp.json (including nested)', matches: s => basenameOf(s) === '.mcp.json', }, { label: '.claude-plugin/plugin.json', matches: s => parentDirOf(s) === '.claude-plugin' && basenameOf(s) === 'plugin.json', }, { label: 'marketplace.json (including nested)', matches: s => basenameOf(s) === 'marketplace.json', }, ]; /** Whether a file (by its root-relative segments) is a config file cclint lints. */ export function isConfigFile(segments) { return CONFIG_FILE_PATTERNS.some(pattern => pattern.matches(segments)); } /** * Walks a project directory and returns the Claude Code config files cclint * understands. * * @remarks * Infrastructure-layer adapter: it is the only place directory traversal (`fs`) * happens for project-wide linting, keeping the domain and CLI free of I/O * concerns. It resolves *what* to lint; reading and linting stay with the * existing `FileReader` / `RulesEngine` pipeline. */ export class FileDiscovery { /** * Discover config files under `rootDir`. * * @param rootDir - Absolute or relative path to the project directory. * @returns Absolute file paths, sorted for deterministic output. */ discover(rootDir) { const matched = []; this.walk(rootDir, [], matched); return matched.sort(); } walk(dir, relativeSegments, matched) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { // An unreadable directory (permissions, race) must not abort the whole // walk — skip it and continue discovering the rest of the tree. return; } for (const entry of entries) { const childSegments = [...relativeSegments, entry.name]; const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if (IGNORED_DIRECTORIES.has(entry.name)) { continue; } this.walk(fullPath, childSegments, matched); } else if (entry.isFile() && isConfigFile(childSegments)) { matched.push(fullPath); } // Symlinks (neither isDirectory nor isFile) are intentionally skipped to // avoid traversal loops and to keep discovery to real files. } } } //# sourceMappingURL=FileDiscovery.js.map