@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
JavaScript
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