UNPKG

@felixgeelhaar/cclint

Version:

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

202 lines 8.23 kB
import { ContextFile } from '../domain/ContextFile.js'; import { Violation } from '../domain/Violation.js'; import { Location } from '../domain/Location.js'; import { Severity } from '../domain/Severity.js'; import { existsSync, readFileSync } from 'fs'; import { resolve, isAbsolute } from 'path'; /** * Rule that validates import resolution and detects circular dependencies * * @remarks * Validates that @path/to/file imports: * - Point to files that actually exist * - Don't create circular dependency chains * - Respect the 5-hop maximum depth limit * - Resolve correctly in the directory hierarchy * * @see {@link https://docs.claude.com/en/docs/claude-code/memory#claude-md-imports | CLAUDE.md import documentation} * * @category Rules */ export class ImportResolutionRule { id = 'import-resolution'; description = 'Validates that imports resolve to existing files and detects circular dependencies'; maxDepth; constructor(maxDepth = 5) { this.maxDepth = maxDepth; } appliesTo(file) { return file.isMarkdown(); } lint(file) { const violations = []; const imports = this.extractImports(file); // Resolve the root file's path to an absolute path before seeding the // cycle-tracking sets. Every nested import is resolved to an absolute path, // so seeding the root with its path *as given* (which may be relative, e.g. // "CLAUDE.md") would let a descendant importing back to the root slip // through the visited check — the cycle would never close on the root. const rootPath = resolve(file.path); // Track import chain for circular dependency detection const importChain = [rootPath]; const visited = new Set([rootPath]); for (const imp of imports) { const resolvedPath = this.resolvePath(imp.path, rootPath); // Check if file exists if (!existsSync(resolvedPath)) { violations.push(new Violation(this.id, `Import "@${imp.path}" resolves to "${resolvedPath}" which does not exist`, Severity.ERROR, new Location(imp.line, imp.column))); continue; } // Check for circular dependencies if (visited.has(resolvedPath)) { const cycle = [...importChain, resolvedPath].join(' → '); violations.push(new Violation(this.id, `Circular import detected: ${cycle}`, Severity.ERROR, new Location(imp.line, imp.column))); continue; } // Validate import depth recursively const depthViolations = this.validateImportDepth(resolvedPath, 1, [...importChain], new Set(visited)); violations.push(...depthViolations); } return violations; } /** * Extract all imports from a file */ extractImports(file) { const imports = []; let inCodeBlock = false; let lineNumber = 0; for (const line of file.lines) { lineNumber++; // Track code block state if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; continue; } // Skip lines inside code blocks if (inCodeBlock) { continue; } // Find all imports in this line const regex = /@([\w\-~/.]+)/g; let match; while ((match = regex.exec(line)) !== null) { const path = match[1]; if (path && !this.isInCodeSpan(line, match.index) && this.isLikelyImport(line, match.index, path)) { imports.push({ path, line: lineNumber, column: match.index, }); } } } return imports; } /** * Decide whether an `@token` is a Claude Code file import rather than an * @-mention, an email, a decorator, or an npm scope. A real import is a file * path, so it must (a) start at a word boundary — never mid-word, which * excludes `user@example.com` — and (b) look like a path: a relative / * absolute / home prefix, or a file extension. This trades a little recall * (a bare `@docs/guide` with no extension is not treated as an import) for a * large drop in false positives on ordinary prose — which were ERRORs that * failed the build. */ isLikelyImport(line, atIndex, path) { const prevChar = atIndex > 0 ? line[atIndex - 1] : ''; if (prevChar && /[\w@]/.test(prevChar)) { return false; // mid-word: an email or a chained mention, not an import } if (/^(\.\.?\/|\/|~\/)/.test(path)) { return true; // ./ ../ / ~/ path prefix } return /\.[A-Za-z0-9]{1,8}$/.test(path); // has a file extension } /** * Check if position is inside a code span */ isInCodeSpan(line, position) { let inSpan = false; let escapeNext = false; for (let i = 0; i < position; i++) { const char = line[i]; if (escapeNext) { escapeNext = false; continue; } if (char === '\\') { escapeNext = true; continue; } if (char === '`') { inSpan = !inSpan; } } return inSpan; } /** * Resolve import path to absolute file path */ resolvePath(importPath, currentFilePath) { const currentDir = currentFilePath.includes('/') ? currentFilePath.substring(0, currentFilePath.lastIndexOf('/')) : '.'; // Handle home directory paths if (importPath.startsWith('~/')) { const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '~'; return resolve(homeDir, importPath.substring(2)); } // Handle absolute paths if (isAbsolute(importPath)) { return resolve(importPath); } // Handle relative paths if (importPath.startsWith('./') || importPath.startsWith('../')) { return resolve(currentDir, importPath); } // Default: relative to current file return resolve(currentDir, importPath); } /** * Validate import depth recursively */ validateImportDepth(filePath, currentDepth, importChain, visited) { const violations = []; // Check max depth if (currentDepth > this.maxDepth) { const chain = [...importChain, filePath].join(' → '); violations.push(new Violation(this.id, `Import chain exceeds maximum depth of ${this.maxDepth} hops: ${chain}`, Severity.ERROR, new Location(1, 1))); return violations; } // Try to read the imported file let importedFile; try { importedFile = new ContextFile(filePath, readFileSync(filePath, 'utf-8')); } catch (_error) { // File might not be a text file or readable return violations; } const nestedImports = this.extractImports(importedFile); const newChain = [...importChain, filePath]; const newVisited = new Set(visited); newVisited.add(filePath); for (const imp of nestedImports) { const resolvedPath = this.resolvePath(imp.path, filePath); // Check for circular dependencies if (newVisited.has(resolvedPath)) { const cycle = [...newChain, resolvedPath].join(' → '); violations.push(new Violation(this.id, `Circular import detected in nested imports: ${cycle}`, Severity.ERROR, new Location(1, 1))); continue; } // Recursively validate nested imports const nestedViolations = this.validateImportDepth(resolvedPath, currentDepth + 1, newChain, newVisited); violations.push(...nestedViolations); } return violations; } } //# sourceMappingURL=ImportResolutionRule.js.map