diff --git a/.gitignore b/.gitignore index c4cab74..9c599f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ dist/ .codebase-index/ .codebase-index.json diff --git a/src/cli-formatters.ts b/src/cli-formatters.ts index 04251ac..dfa15b7 100644 --- a/src/cli-formatters.ts +++ b/src/cli-formatters.ts @@ -269,7 +269,14 @@ export function formatSearch( if (impact?.coverage) { boxLines.push(`Callers: ${impact.coverage}`); } - if (impact?.files && impact.files.length > 0) { + if (impact?.details && impact.details.length > 0) { + const shown = impact.details.slice(0, 3).map((d) => { + const p = shortPath(d.file, rootPath); + const suffix = d.line ? `:${d.line}` : ''; + return `${p}${suffix} (hop ${d.hop})`; + }); + boxLines.push(`Files: ${shown.join(', ')}`); + } else if (impact?.files && impact.files.length > 0) { const shown = impact.files.slice(0, 3).map((f) => shortPath(f, rootPath)); boxLines.push(`Files: ${shown.join(', ')}`); } diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 508dd5b..4b586c6 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -515,7 +515,7 @@ export class CodebaseIndexer { } } - internalFileGraph.trackImport(file, resolvedPath, imp.imports); + internalFileGraph.trackImport(file, resolvedPath, imp.line || 1, imp.imports); } } @@ -856,6 +856,7 @@ export class CodebaseIndexer { generatedAt, graph: { imports: graphData.imports || {}, + ...(graphData.importDetails ? { importDetails: graphData.importDetails } : {}), importedBy, exports: graphData.exports || {} }, diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index dc5981d..9e6081c 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -27,6 +27,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js'; interface RelationshipsData { graph?: { imports?: Record; + importDetails?: Record>; }; stats?: unknown; } @@ -280,6 +281,35 @@ export async function handle( return null; } + type ImportEdgeDetail = { line?: number; importedSymbols?: string[] }; + type ImportDetailsGraph = Record>; + + function getImportDetailsGraph(): ImportDetailsGraph | null { + if (relationships?.graph?.importDetails) { + return relationships.graph.importDetails as ImportDetailsGraph; + } + const internalDetails = intelligence?.internalFileGraph?.importDetails; + if (internalDetails) { + return internalDetails as ImportDetailsGraph; + } + return null; + } + + function normalizeGraphPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + if (path.isAbsolute(filePath)) { + const rel = path.relative(ctx.rootPath, filePath).replace(/\\/g, '/'); + if (rel && !rel.startsWith('..')) { + return rel; + } + } + return normalized.replace(/^\.\//, ''); + } + + function pathsMatch(a: string, b: string): boolean { + return a === b || a.endsWith(b) || b.endsWith(a); + } + function computeIndexConfidence(): 'fresh' | 'aging' | 'stale' { let confidence: 'fresh' | 'aging' | 'stale' = 'stale'; if (intelligence?.generatedAt) { @@ -294,21 +324,92 @@ export async function handle( return confidence; } - // Cheap impact breadth estimate from the import graph (used for risk assessment). - function computeImpactCandidates(resultPaths: string[]): string[] { - const impactCandidates: string[] = []; + type ImpactCandidate = { file: string; line?: number; hop: 1 | 2 }; + + function findImportDetail( + details: ImportDetailsGraph | null, + importer: string, + imported: string + ): ImportEdgeDetail | null { + if (!details) return null; + const edges = details[importer]; + if (!edges) return null; + if (edges[imported]) return edges[imported]; + + let bestKey: string | null = null; + for (const depKey of Object.keys(edges)) { + if (!pathsMatch(depKey, imported)) continue; + if (!bestKey || depKey.length > bestKey.length) { + bestKey = depKey; + } + } + + return bestKey ? edges[bestKey] : null; + } + + // Impact breadth estimate from the import graph (used for risk assessment). + // 2-hop: direct importers (hop 1) + importers of importers (hop 2). + function computeImpactCandidates(resultPaths: string[]): ImpactCandidate[] { const allImports = getImportsGraph(); - if (!allImports) return impactCandidates; - for (const [file, deps] of Object.entries(allImports)) { - if ( - deps.some((dep: string) => resultPaths.some((rp) => dep.endsWith(rp) || rp.endsWith(dep))) - ) { - if (!resultPaths.some((rp) => file.endsWith(rp) || rp.endsWith(file))) { - impactCandidates.push(file); + if (!allImports) return []; + + const importDetails = getImportDetailsGraph(); + + const reverseImportsLocal = new Map(); + for (const [file, deps] of Object.entries(allImports)) { + for (const dep of deps) { + if (!reverseImportsLocal.has(dep)) reverseImportsLocal.set(dep, []); + reverseImportsLocal.get(dep)!.push(file); + } + } + + const targets = resultPaths.map((rp) => normalizeGraphPath(rp)); + const targetSet = new Set(targets); + + const candidates = new Map(); + + const addCandidate = (file: string, hop: 1 | 2, line?: number): void => { + if (Array.from(targetSet).some((t) => pathsMatch(t, file))) return; + + const existing = candidates.get(file); + if (existing) { + if (existing.hop <= hop) return; + } + candidates.set(file, { file, hop, ...(line ? { line } : {}) }); + }; + + const collectImporters = ( + target: string + ): Array<{ importer: string; detail: ImportEdgeDetail | null }> => { + const matches: Array<{ importer: string; detail: ImportEdgeDetail | null }> = []; + for (const [dep, importers] of reverseImportsLocal) { + if (!pathsMatch(dep, target)) continue; + for (const importer of importers) { + matches.push({ importer, detail: findImportDetail(importDetails, importer, dep) }); } } + return matches; + }; + + // Hop 1 + const hop1Files: string[] = []; + for (const target of targets) { + for (const { importer, detail } of collectImporters(target)) { + addCandidate(importer, 1, detail?.line); + } + } + for (const candidate of candidates.values()) { + if (candidate.hop === 1) hop1Files.push(candidate.file); } - return impactCandidates; + + // Hop 2 + for (const mid of hop1Files) { + for (const { importer, detail } of collectImporters(mid)) { + addCandidate(importer, 2, detail?.line); + } + } + + return Array.from(candidates.values()).slice(0, 20); } // Build reverse import map from relationships sidecar (preferred) or intelligence graph @@ -673,12 +774,18 @@ export async function handle( // Add impact (coverage + top 3 files) if (impactCoverage || impactCandidates.length > 0) { - const impactObj: { coverage?: string; files?: string[] } = {}; + const impactObj: { + coverage?: string; + files?: string[]; + details?: Array<{ file: string; line?: number; hop: 1 | 2 }>; + } = {}; if (impactCoverage) { impactObj.coverage = `${impactCoverage.covered}/${impactCoverage.total} callers in results`; } if (impactCandidates.length > 0) { - impactObj.files = impactCandidates.slice(0, 3); + const top = impactCandidates.slice(0, 3); + impactObj.files = top.map((candidate) => candidate.file); + impactObj.details = top; } if (Object.keys(impactObj).length > 0) { decisionCard.impact = impactObj; diff --git a/src/tools/types.ts b/src/tools/types.ts index 8f1e011..76541cd 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -13,6 +13,7 @@ export interface DecisionCard { impact?: { coverage?: string; files?: string[]; + details?: Array<{ file: string; line?: number; hop: 1 | 2 }>; }; whatWouldHelp?: string[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 2c3c33d..ca39bea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -647,6 +647,7 @@ export interface IntelligenceData { /** Opaque — consumed via InternalFileGraph.fromJSON */ internalFileGraph?: { imports?: Record; + importDetails?: Record>; exports?: Record; stats?: unknown; }; diff --git a/src/utils/usage-tracker.ts b/src/utils/usage-tracker.ts index 6150ac2..23bedcb 100644 --- a/src/utils/usage-tracker.ts +++ b/src/utils/usage-tracker.ts @@ -622,6 +622,11 @@ export interface UnusedExport { exports: string[]; } +export interface ImportEdgeDetail { + line?: number; + importedSymbols?: string[]; +} + export class InternalFileGraph { // Map: normalized file path -> Set of normalized file paths it imports private imports: Map> = new Map(); @@ -629,6 +634,8 @@ export class InternalFileGraph { private exports: Map = new Map(); // Map: normalized file path -> Set of what symbols are imported from this file private importedSymbols: Map> = new Map(); + // Map: fromFile -> toFile -> edge details (line/symbols) + private importDetails: Map> = new Map(); // Root path for relative path conversion private rootPath: string; @@ -663,7 +670,12 @@ export class InternalFileGraph { * Track that importingFile imports importedFile * Both should be absolute paths; they will be normalized internally. */ - trackImport(importingFile: string, importedFile: string, importedSymbols?: string[]): void { + trackImport( + importingFile: string, + importedFile: string, + line?: number, + importedSymbols?: string[] + ): void { const fromFile = this.normalizePath(importingFile); const toFile = this.normalizePath(importedFile); @@ -674,15 +686,43 @@ export class InternalFileGraph { this.imports.get(fromFile)!.add(toFile); - // Track which symbols are imported from the target file - if (importedSymbols && importedSymbols.length > 0) { + const normalizedLine = + typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined; + + const normalizedSymbols = + importedSymbols && importedSymbols.length > 0 + ? importedSymbols.filter((sym) => sym !== '*' && sym !== 'default') + : []; + + if (normalizedLine || normalizedSymbols.length > 0) { + if (!this.importDetails.has(fromFile)) { + this.importDetails.set(fromFile, new Map()); + } + const detailsForFrom = this.importDetails.get(fromFile)!; + const existing = detailsForFrom.get(toFile) ?? {}; + + const mergedLine = + existing.line && normalizedLine + ? Math.min(existing.line, normalizedLine) + : (existing.line ?? normalizedLine); + + const mergedSymbolsSet = new Set(existing.importedSymbols ?? []); + for (const sym of normalizedSymbols) mergedSymbolsSet.add(sym); + const mergedSymbols = Array.from(mergedSymbolsSet); + + detailsForFrom.set(toFile, { + ...(mergedLine ? { line: mergedLine } : {}), + ...(mergedSymbols.length > 0 ? { importedSymbols: mergedSymbols.sort() } : {}) + }); + } + + // Track which symbols are imported from the target file (for unused export detection) + if (normalizedSymbols.length > 0) { if (!this.importedSymbols.has(toFile)) { this.importedSymbols.set(toFile, new Set()); } - for (const sym of importedSymbols) { - if (sym !== '*' && sym !== 'default') { - this.importedSymbols.get(toFile)!.add(sym); - } + for (const sym of normalizedSymbols) { + this.importedSymbols.get(toFile)!.add(sym); } } } @@ -824,6 +864,7 @@ export class InternalFileGraph { toJSON(): { imports: Record; exports: Record; + importDetails?: Record>; stats: { files: number; edges: number; avgDependencies: number }; } { const imports: Record = {}; @@ -836,7 +877,25 @@ export class InternalFileGraph { exports[file] = exps; } - return { imports, exports, stats: this.getStats() }; + const importDetails: Record> = {}; + for (const [fromFile, edges] of this.importDetails.entries()) { + const nested: Record = {}; + for (const [toFile, detail] of edges.entries()) { + if (!detail.line && (!detail.importedSymbols || detail.importedSymbols.length === 0)) + continue; + nested[toFile] = detail; + } + if (Object.keys(nested).length > 0) { + importDetails[fromFile] = nested; + } + } + + return { + imports, + exports, + ...(Object.keys(importDetails).length > 0 ? { importDetails } : {}), + stats: this.getStats() + }; } /** @@ -846,6 +905,7 @@ export class InternalFileGraph { data: { imports?: Record; exports?: Record; + importDetails?: Record>; }, rootPath: string ): InternalFileGraph { @@ -863,6 +923,27 @@ export class InternalFileGraph { } } + if (data.importDetails) { + for (const [fromFile, edges] of Object.entries(data.importDetails)) { + const edgeMap = new Map(); + for (const [toFile, detail] of Object.entries(edges ?? {})) { + edgeMap.set(toFile, detail); + + if (detail.importedSymbols && detail.importedSymbols.length > 0) { + if (!graph.importedSymbols.has(toFile)) { + graph.importedSymbols.set(toFile, new Set()); + } + for (const sym of detail.importedSymbols) { + graph.importedSymbols.get(toFile)!.add(sym); + } + } + } + if (edgeMap.size > 0) { + graph.importDetails.set(fromFile, edgeMap); + } + } + } + return graph; } }