UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

412 lines (411 loc) • 15.7 kB
/** * Dependency Checker for pi-local * * Real-time circular dependency detection. * Caches the dependency graph and only re-scans when imports change. * Runs in the tool_result hook like ast-grep and Biome. * * Requires: npm install -D madge * Docs: https://github.com/pahen/madge */ import * as fs from "node:fs"; import * as path from "node:path"; import { findNodeToolBinary } from "./package-manager.js"; import { safeSpawnAsync } from "./safe-spawn.js"; /** * Build madge's argv for a circular-dependency scan. * * Two correctness levers beyond the bare `--circular`: * - `mjs,cjs` extensions: without them madge ignores explicit ESM/CJS files, * dropping their edges from the graph. * - `--ts-config <tsconfig.json>`: madge can only resolve TypeScript `paths` * aliases (`@/foo`, `~/bar`) when pointed at the tsconfig. Without it those * imports are silently unresolved, so cycles that route through an alias are * MISSED (false negatives). Passed only when a tsconfig actually exists, so * non-TS / alias-free projects are unaffected. */ export function buildMadgeArgs(target, projectRoot) { const args = ["--circular", "--extensions", "ts,tsx,js,jsx,mjs,cjs"]; const tsConfig = path.join(projectRoot, "tsconfig.json"); if (fs.existsSync(tsConfig)) { args.push("--ts-config", tsConfig); } // --warning surfaces files madge couldn't resolve into the graph (to stderr; // stdout JSON is unaffected). Without it those skips are SILENT — and a // skipped *local* file could hide a real cycle. We log them (see // parseMadgeSkips) instead of discarding stderr. args.push("--warning", "--json", target); return args; } /** * Parse madge's `--warning` stderr for skipped (unresolvable) files. External * package specifiers (bare names / subpaths like `web-tree-sitter`, * `vitest/config`) are expected — madge doesn't traverse node_modules. We flag * only **local** skips (relative/absolute paths), which are the ones that could * silently drop an internal edge and hide a cycle. * * @returns total skip count and the subset that look local. */ export function parseMadgeSkips(stderr) { const lines = (stderr || "").split(/\r?\n/); const headerIdx = lines.findIndex((l) => /Skipped\s+\d+\s+file/i.test(l)); if (headerIdx === -1) return { total: 0, local: [] }; const total = Number.parseInt(lines[headerIdx].match(/Skipped\s+(\d+)/i)?.[1] ?? "0", 10) || 0; const specifiers = lines .slice(headerIdx + 1) .map((l) => l.trim()) .filter(Boolean); const local = specifiers.filter((s) => s.startsWith(".") || s.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(s)); return { total, local }; } // --- Client --- export class DependencyChecker { available = null; ensureInFlight = null; checkInFlight = new Map(); scanInFlight = new Map(); log; // Cache: file path -> its imports importCache = new Map(); // Circular deps: last known circular deps lastCircular = []; // Files that are part of a circular dependency circularFiles = new Set(); constructor(verbose = false) { this.log = verbose ? (msg) => console.error(`[deps] ${msg}`) : () => { }; } /** * Resolve how to invoke madge for `cwd`: a local/global-installed binary * (npm/pnpm/yarn/bun) if found, else `npx madge` (#375). `prefix` is prepended * to the madge args (empty for a resolved binary, `["madge"]` for the npx * fallback). */ async resolveMadge(cwd) { const bin = await findNodeToolBinary("madge", cwd); return bin ? { cmd: bin, prefix: [] } : { cmd: "npx", prefix: ["madge"] }; } /** * Check if madge is available, auto-install if not */ async ensureAvailable() { // Fast path: already checked if (this.available !== null) return this.available; if (this.ensureInFlight) return this.ensureInFlight; this.ensureInFlight = this.doEnsureAvailable(); try { return await this.ensureInFlight; } finally { this.ensureInFlight = null; } } async doEnsureAvailable() { // Check if available in PATH const result = await safeSpawnAsync("madge", ["--version"], { timeout: 5000, }); this.available = !result.error && result.status === 0; if (this.available) { this.log(`Madge found: ${result.stdout?.trim()}`); return true; } // Auto-install via pi-lens installer this.log("Madge not found, attempting auto-install..."); const { ensureTool } = await import("./installer/index.js"); const installedPath = await ensureTool("madge"); if (installedPath) { this.log(`Madge auto-installed: ${installedPath}`); this.available = true; return true; } this.available = false; this.log("Madge auto-install failed"); return false; } /** * Check if a file is part of a circular dependency (from cache) */ isInCircular(filePath) { const normalized = path.resolve(filePath); return this.circularFiles.has(normalized); } /** * Get circular deps for a specific file */ getCircularForFile(filePath) { const normalized = path.resolve(filePath); const deps = []; for (const dep of this.lastCircular) { if (dep.file === normalized || dep.path.includes(normalized)) { // Add the other files in the cycle for (const f of dep.path) { if (f !== normalized) { deps.push(path.relative(process.cwd(), f)); } } } } return Array.from(new Set(deps)); } /** * Extract imports from a TypeScript/JavaScript file */ extractImports(filePath) { const content = fs.readFileSync(filePath, "utf-8"); const imports = new Set(); // Match import statements: import ... from '...' const importPattern = /(?:import|export)\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g; const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; let match; while ((match = importPattern.exec(content)) !== null) { if (match[1].startsWith(".")) { imports.add(match[1]); } } while ((match = requirePattern.exec(content)) !== null) { if (match[1].startsWith(".")) { imports.add(match[1]); } } return imports; } /** * Check if imports have changed for a file */ importsChanged(filePath) { const normalized = path.resolve(filePath); if (!fs.existsSync(normalized)) { this.importCache.delete(normalized); return true; } const stat = fs.statSync(normalized); const cached = this.importCache.get(normalized); // Fast path: timestamp hasn't changed if (cached && cached.timestamp >= stat.mtimeMs) { return false; } // Compare actual imports const newImports = this.extractImports(normalized); const hasChanged = !cached || !this.setsEqual(cached.imports, newImports); // Update cache this.importCache.set(normalized, { imports: newImports, timestamp: stat.mtimeMs, }); return hasChanged; } /** * Check if two sets have the same elements */ setsEqual(a, b) { if (a.size !== b.size) return false; for (const item of a) { if (!b.has(item)) return false; } return true; } /** * Quick circular dependency check using DFS on cached graph. * Only re-runs full madge check when imports change. */ async checkFile(filePath, cwd) { const normalized = path.resolve(filePath); // Return early for non-existent files without running availability check if (!fs.existsSync(normalized)) { return { hasCircular: false, circular: [], checked: false, cacheHit: false, }; } const projectRoot = path.resolve(cwd || process.cwd()); // Check if imports changed before probing/installing madge. const importsChanged = this.importsChanged(normalized); if (!importsChanged) { // Return cached result return { hasCircular: this.circularFiles.has(normalized), circular: this.lastCircular.filter((d) => d.file === normalized || d.path.includes(normalized)), checked: true, cacheHit: true, }; } if (!(await this.ensureAvailable())) { return { hasCircular: false, circular: [], checked: false, cacheHit: false, }; } const key = `${projectRoot}:${normalized}`; const existing = this.checkInFlight.get(key); if (existing) return existing; const promise = this.runCheckFile(normalized, projectRoot).finally(() => { this.checkInFlight.delete(key); }); this.checkInFlight.set(key, promise); return promise; } async runCheckFile(normalized, projectRoot) { this.log(`Imports changed for ${path.basename(normalized)}, checking dependencies...`); // Run madge on the specific file (fast) try { const { cmd, prefix } = await this.resolveMadge(projectRoot); const result = await safeSpawnAsync(cmd, [...prefix, ...buildMadgeArgs(normalized, projectRoot)], { timeout: 15000, cwd: projectRoot, }); if (result.error) { this.log(`Check error: ${result.error.message}`); return { hasCircular: false, circular: [], checked: false, cacheHit: false, }; } const output = result.stdout || "[]"; const parsed = JSON.parse(output); // Madge --circular --json returns array of cycle arrays: [["a.ts", "b.ts"], ...] const cycles = Array.isArray(parsed) ? parsed : []; const circular = []; const circularFiles = new Set(); for (const cycle of cycles) { const resolvedPaths = cycle.map((f) => path.resolve(projectRoot, f)); for (const f of resolvedPaths) { circularFiles.add(f); } circular.push({ file: resolvedPaths[0], path: resolvedPaths, }); } this.lastCircular = circular; this.circularFiles = circularFiles; const skips = parseMadgeSkips(result.stderr || ""); if (skips.local.length > 0) { this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`); } return { hasCircular: circular.length > 0, circular: circular.filter((d) => d.file === normalized || d.path.includes(normalized)), checked: true, cacheHit: false, localSkips: skips.local.length, }; } catch (err) { this.log(`Check error: ${err.message}`); return { hasCircular: false, circular: [], checked: false, cacheHit: false, }; } } /** * Format circular dependency warning for LLM */ formatWarning(filePath, deps) { if (deps.length === 0) return ""; const filename = path.basename(filePath); const depNames = deps.map((d) => path.basename(d)); let output = `[Circular Deps] ${filename} is in a cycle:\n`; output += ` ${filename} ↔ ${depNames.join(", ")}\n`; output += `\n Consider extracting shared code to a separate module.\n`; return output; } /** * Full project scan (for /check-deps command) */ async scanProject(cwd) { const projectRoot = path.resolve(cwd || process.cwd()); // Return early for non-existent or empty directories before probing/installing. if (!fs.existsSync(projectRoot)) { return { circular: [], count: 0 }; } const entries = fs.readdirSync(projectRoot); const hasSourceFiles = entries.some((e) => /\.(ts|tsx|js|jsx)$/.test(e) && !e.endsWith(".d.ts")); if (!hasSourceFiles) { return { circular: [], count: 0 }; } if (!(await this.ensureAvailable())) { return { circular: [], count: 0 }; } const existing = this.scanInFlight.get(projectRoot); if (existing) return existing; const promise = this.runScanProject(projectRoot).finally(() => { this.scanInFlight.delete(projectRoot); }); this.scanInFlight.set(projectRoot, promise); return promise; } async runScanProject(projectRoot) { try { const { cmd, prefix } = await this.resolveMadge(projectRoot); const result = await safeSpawnAsync(cmd, [...prefix, ...buildMadgeArgs(projectRoot, projectRoot)], { timeout: 30000, cwd: projectRoot, }); if (result.error) { this.log(`Scan error: ${result.error.message}`); return { circular: [], count: 0 }; } const output = result.stdout || "{}"; const data = JSON.parse(output); const circular = []; const circularFiles = new Set(); for (const [file, deps] of Object.entries(data)) { if (Array.isArray(deps) && deps.length > 0) { const resolvedFile = path.resolve(file); circularFiles.add(resolvedFile); circular.push({ file: resolvedFile, path: [resolvedFile, ...deps.map((d) => path.resolve(d))], }); } } this.lastCircular = circular; this.circularFiles = circularFiles; return { circular, count: circular.length }; } catch (err) { this.log(`Scan error: ${err.message}`); return { circular: [], count: 0 }; } } /** * Format full scan results */ formatScanResult(circular) { if (circular.length === 0) return ""; // Group by cycle to avoid duplicate entries const seen = new Set(); let output = `[Circular Deps] ${circular.length} cycle(s) found:\n`; for (const dep of circular) { const cycleKey = dep.path.sort((a, b) => a.localeCompare(b)).join("→"); if (seen.has(cycleKey)) continue; seen.add(cycleKey); const names = dep.path.map((p) => path.relative(process.cwd(), p)); output += ` • ${names.join(" → ")}\n`; } output += "\n Consider extracting shared code to break cycles.\n"; return output; } }