185 lines
5.6 KiB
JavaScript
185 lines
5.6 KiB
JavaScript
/**
|
|
* 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<boolean>}
|
|
*/
|
|
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<string>} 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<object>}
|
|
*/
|
|
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);
|
|
}
|