/** * IMP Scanner — Import Resolver * Resolves @import references in CLAUDE.md files: broken links, circular refs, deep chains. * Finding IDs: CA-IMP-NNN */ import { resolve, dirname, basename } from 'node:path'; import { tmpdir } from 'node:os'; import { stat } from 'node:fs/promises'; import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { findImports } from './lib/yaml-parser.mjs'; import { truncate } from './lib/string-utils.mjs'; const SCANNER = 'IMP'; const MAX_CHAIN_DEPTH = 5; const HARD_LIMIT = 20; /** * Check if a file exists. * @param {string} absPath * @returns {Promise} */ async function fileExists(absPath) { try { await stat(absPath); return true; } catch { return false; } } /** * Resolve an import path relative to the containing file. * @param {string} importPath * @param {string} containingFile * @returns {{ resolved: string, hasTilde: boolean }} */ function resolveImportPath(importPath, containingFile) { const hasTilde = importPath.startsWith('~'); let resolved; if (hasTilde) { const home = process.env.HOME || process.env.USERPROFILE || tmpdir(); resolved = resolve(importPath.replace(/^~/, home)); } else if (importPath.startsWith('/')) { resolved = importPath; } else { resolved = resolve(dirname(containingFile), importPath); } return { resolved, hasTilde }; } /** * Walk imports recursively from a starting file via DFS. * @param {string} file - Absolute path to current file * @param {string[]} chain - Current chain of files (for cycle detection) * @param {Set} reported - Set of "from::to" pairs already reported * @param {object[]} findings - Accumulator for findings */ async function walkImports(file, chain, reported, findings) { const content = await readTextFile(file); if (!content) return; const imports = findImports(content); for (const imp of imports) { const { resolved, hasTilde } = resolveImportPath(imp.path, file); const reportKey = `${file}::${resolved}`; // Tilde path warning if (hasTilde && !reported.has(`tilde::${resolved}`)) { reported.add(`tilde::${resolved}`); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Tilde path in @import', description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`, file, line: imp.line, evidence: `@${imp.path}`, recommendation: 'Use a relative path or absolute path without tilde expansion.', })); } // Check file existence const exists = await fileExists(resolved); if (!exists) { if (!reported.has(reportKey)) { reported.add(reportKey); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Broken @import link', description: `@${imp.path} references a file that does not exist.`, file, line: imp.line, evidence: `@${imp.path} → ${truncate(resolved, 80)}`, recommendation: 'Fix the path or create the missing file.', })); } continue; } // Circular reference detection if (chain.includes(resolved)) { if (!reported.has(reportKey)) { reported.add(reportKey); const cycleStart = chain.indexOf(resolved); const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → '); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Circular @import reference', description: `@${imp.path} creates a circular import chain.`, file, line: imp.line, evidence: `${cycle} → ${basename(resolved)}`, recommendation: 'Break the circular dependency by removing one of the @imports.', })); } continue; } // Deep chain warning if (chain.length >= MAX_CHAIN_DEPTH) { if (!reported.has(`deep::${resolved}`)) { reported.add(`deep::${resolved}`); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Deep @import chain', description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`, file, line: imp.line, evidence: `Chain depth: ${chain.length}`, recommendation: 'Flatten the import hierarchy to reduce nesting.', })); } continue; } // Hard limit safety bail if (chain.length >= HARD_LIMIT) continue; // Recurse await walkImports(resolved, [...chain, resolved], reported, findings); } } /** * Scan all CLAUDE.md files for @import issues. * @param {string} targetPath * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise} */ export async function scan(targetPath, discovery) { const start = Date.now(); const claudeMdFiles = discovery.files.filter(f => f.type === 'claude-md'); const findings = []; let filesScanned = 0; if (claudeMdFiles.length === 0) { return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start); } const reported = new Set(); for (const file of claudeMdFiles) { const content = await readTextFile(file.absPath); if (!content) continue; const imports = findImports(content); if (imports.length === 0) { filesScanned++; continue; } filesScanned++; await walkImports(file.absPath, [file.absPath], reported, findings); } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }