ktg-plugin-marketplace/plugins/config-audit/scanners/import-resolver.mjs

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);
}