Skip to content

Commit f296e30

Browse files
authored
Merge pull request #50 from PatrickSys/feat/phase2-impact-details
feat(impact): persist import edge details + 2-hop impact candidates
2 parents c23ffec + 5bd84a1 commit f296e30

File tree

7 files changed

+222
-23
lines changed

7 files changed

+222
-23
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
node_modules/
2+
.pnpm-store/
23
dist/
34
.codebase-index/
45
.codebase-index.json

src/cli-formatters.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,14 @@ export function formatSearch(
269269
if (impact?.coverage) {
270270
boxLines.push(`Callers: ${impact.coverage}`);
271271
}
272-
if (impact?.files && impact.files.length > 0) {
272+
if (impact?.details && impact.details.length > 0) {
273+
const shown = impact.details.slice(0, 3).map((d) => {
274+
const p = shortPath(d.file, rootPath);
275+
const suffix = d.line ? `:${d.line}` : '';
276+
return `${p}${suffix} (hop ${d.hop})`;
277+
});
278+
boxLines.push(`Files: ${shown.join(', ')}`);
279+
} else if (impact?.files && impact.files.length > 0) {
273280
const shown = impact.files.slice(0, 3).map((f) => shortPath(f, rootPath));
274281
boxLines.push(`Files: ${shown.join(', ')}`);
275282
}

src/core/indexer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ export class CodebaseIndexer {
515515
}
516516
}
517517

518-
internalFileGraph.trackImport(file, resolvedPath, imp.imports);
518+
internalFileGraph.trackImport(file, resolvedPath, imp.line || 1, imp.imports);
519519
}
520520
}
521521

@@ -856,6 +856,7 @@ export class CodebaseIndexer {
856856
generatedAt,
857857
graph: {
858858
imports: graphData.imports || {},
859+
...(graphData.importDetails ? { importDetails: graphData.importDetails } : {}),
859860
importedBy,
860861
exports: graphData.exports || {}
861862
},

src/tools/search-codebase.ts

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
2727
interface RelationshipsData {
2828
graph?: {
2929
imports?: Record<string, string[]>;
30+
importDetails?: Record<string, Record<string, { line?: number; importedSymbols?: string[] }>>;
3031
};
3132
stats?: unknown;
3233
}
@@ -280,6 +281,35 @@ export async function handle(
280281
return null;
281282
}
282283

284+
type ImportEdgeDetail = { line?: number; importedSymbols?: string[] };
285+
type ImportDetailsGraph = Record<string, Record<string, ImportEdgeDetail>>;
286+
287+
function getImportDetailsGraph(): ImportDetailsGraph | null {
288+
if (relationships?.graph?.importDetails) {
289+
return relationships.graph.importDetails as ImportDetailsGraph;
290+
}
291+
const internalDetails = intelligence?.internalFileGraph?.importDetails;
292+
if (internalDetails) {
293+
return internalDetails as ImportDetailsGraph;
294+
}
295+
return null;
296+
}
297+
298+
function normalizeGraphPath(filePath: string): string {
299+
const normalized = filePath.replace(/\\/g, '/');
300+
if (path.isAbsolute(filePath)) {
301+
const rel = path.relative(ctx.rootPath, filePath).replace(/\\/g, '/');
302+
if (rel && !rel.startsWith('..')) {
303+
return rel;
304+
}
305+
}
306+
return normalized.replace(/^\.\//, '');
307+
}
308+
309+
function pathsMatch(a: string, b: string): boolean {
310+
return a === b || a.endsWith(b) || b.endsWith(a);
311+
}
312+
283313
function computeIndexConfidence(): 'fresh' | 'aging' | 'stale' {
284314
let confidence: 'fresh' | 'aging' | 'stale' = 'stale';
285315
if (intelligence?.generatedAt) {
@@ -294,21 +324,92 @@ export async function handle(
294324
return confidence;
295325
}
296326

297-
// Cheap impact breadth estimate from the import graph (used for risk assessment).
298-
function computeImpactCandidates(resultPaths: string[]): string[] {
299-
const impactCandidates: string[] = [];
327+
type ImpactCandidate = { file: string; line?: number; hop: 1 | 2 };
328+
329+
function findImportDetail(
330+
details: ImportDetailsGraph | null,
331+
importer: string,
332+
imported: string
333+
): ImportEdgeDetail | null {
334+
if (!details) return null;
335+
const edges = details[importer];
336+
if (!edges) return null;
337+
if (edges[imported]) return edges[imported];
338+
339+
let bestKey: string | null = null;
340+
for (const depKey of Object.keys(edges)) {
341+
if (!pathsMatch(depKey, imported)) continue;
342+
if (!bestKey || depKey.length > bestKey.length) {
343+
bestKey = depKey;
344+
}
345+
}
346+
347+
return bestKey ? edges[bestKey] : null;
348+
}
349+
350+
// Impact breadth estimate from the import graph (used for risk assessment).
351+
// 2-hop: direct importers (hop 1) + importers of importers (hop 2).
352+
function computeImpactCandidates(resultPaths: string[]): ImpactCandidate[] {
300353
const allImports = getImportsGraph();
301-
if (!allImports) return impactCandidates;
302-
for (const [file, deps] of Object.entries(allImports)) {
303-
if (
304-
deps.some((dep: string) => resultPaths.some((rp) => dep.endsWith(rp) || rp.endsWith(dep)))
305-
) {
306-
if (!resultPaths.some((rp) => file.endsWith(rp) || rp.endsWith(file))) {
307-
impactCandidates.push(file);
354+
if (!allImports) return [];
355+
356+
const importDetails = getImportDetailsGraph();
357+
358+
const reverseImportsLocal = new Map<string, string[]>();
359+
for (const [file, deps] of Object.entries<string[]>(allImports)) {
360+
for (const dep of deps) {
361+
if (!reverseImportsLocal.has(dep)) reverseImportsLocal.set(dep, []);
362+
reverseImportsLocal.get(dep)!.push(file);
363+
}
364+
}
365+
366+
const targets = resultPaths.map((rp) => normalizeGraphPath(rp));
367+
const targetSet = new Set(targets);
368+
369+
const candidates = new Map<string, ImpactCandidate>();
370+
371+
const addCandidate = (file: string, hop: 1 | 2, line?: number): void => {
372+
if (Array.from(targetSet).some((t) => pathsMatch(t, file))) return;
373+
374+
const existing = candidates.get(file);
375+
if (existing) {
376+
if (existing.hop <= hop) return;
377+
}
378+
candidates.set(file, { file, hop, ...(line ? { line } : {}) });
379+
};
380+
381+
const collectImporters = (
382+
target: string
383+
): Array<{ importer: string; detail: ImportEdgeDetail | null }> => {
384+
const matches: Array<{ importer: string; detail: ImportEdgeDetail | null }> = [];
385+
for (const [dep, importers] of reverseImportsLocal) {
386+
if (!pathsMatch(dep, target)) continue;
387+
for (const importer of importers) {
388+
matches.push({ importer, detail: findImportDetail(importDetails, importer, dep) });
308389
}
309390
}
391+
return matches;
392+
};
393+
394+
// Hop 1
395+
const hop1Files: string[] = [];
396+
for (const target of targets) {
397+
for (const { importer, detail } of collectImporters(target)) {
398+
addCandidate(importer, 1, detail?.line);
399+
}
400+
}
401+
for (const candidate of candidates.values()) {
402+
if (candidate.hop === 1) hop1Files.push(candidate.file);
310403
}
311-
return impactCandidates;
404+
405+
// Hop 2
406+
for (const mid of hop1Files) {
407+
for (const { importer, detail } of collectImporters(mid)) {
408+
addCandidate(importer, 2, detail?.line);
409+
}
410+
}
411+
412+
return Array.from(candidates.values()).slice(0, 20);
312413
}
313414

314415
// Build reverse import map from relationships sidecar (preferred) or intelligence graph
@@ -673,12 +774,18 @@ export async function handle(
673774

674775
// Add impact (coverage + top 3 files)
675776
if (impactCoverage || impactCandidates.length > 0) {
676-
const impactObj: { coverage?: string; files?: string[] } = {};
777+
const impactObj: {
778+
coverage?: string;
779+
files?: string[];
780+
details?: Array<{ file: string; line?: number; hop: 1 | 2 }>;
781+
} = {};
677782
if (impactCoverage) {
678783
impactObj.coverage = `${impactCoverage.covered}/${impactCoverage.total} callers in results`;
679784
}
680785
if (impactCandidates.length > 0) {
681-
impactObj.files = impactCandidates.slice(0, 3);
786+
const top = impactCandidates.slice(0, 3);
787+
impactObj.files = top.map((candidate) => candidate.file);
788+
impactObj.details = top;
682789
}
683790
if (Object.keys(impactObj).length > 0) {
684791
decisionCard.impact = impactObj;

src/tools/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface DecisionCard {
1313
impact?: {
1414
coverage?: string;
1515
files?: string[];
16+
details?: Array<{ file: string; line?: number; hop: 1 | 2 }>;
1617
};
1718
whatWouldHelp?: string[];
1819
}

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ export interface IntelligenceData {
647647
/** Opaque — consumed via InternalFileGraph.fromJSON */
648648
internalFileGraph?: {
649649
imports?: Record<string, string[]>;
650+
importDetails?: Record<string, Record<string, { line?: number; importedSymbols?: string[] }>>;
650651
exports?: Record<string, unknown[]>;
651652
stats?: unknown;
652653
};

src/utils/usage-tracker.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -622,13 +622,20 @@ export interface UnusedExport {
622622
exports: string[];
623623
}
624624

625+
export interface ImportEdgeDetail {
626+
line?: number;
627+
importedSymbols?: string[];
628+
}
629+
625630
export class InternalFileGraph {
626631
// Map: normalized file path -> Set of normalized file paths it imports
627632
private imports: Map<string, Set<string>> = new Map();
628633
// Map: normalized file path -> exports from that file
629634
private exports: Map<string, FileExport[]> = new Map();
630635
// Map: normalized file path -> Set of what symbols are imported from this file
631636
private importedSymbols: Map<string, Set<string>> = new Map();
637+
// Map: fromFile -> toFile -> edge details (line/symbols)
638+
private importDetails: Map<string, Map<string, ImportEdgeDetail>> = new Map();
632639
// Root path for relative path conversion
633640
private rootPath: string;
634641

@@ -663,7 +670,12 @@ export class InternalFileGraph {
663670
* Track that importingFile imports importedFile
664671
* Both should be absolute paths; they will be normalized internally.
665672
*/
666-
trackImport(importingFile: string, importedFile: string, importedSymbols?: string[]): void {
673+
trackImport(
674+
importingFile: string,
675+
importedFile: string,
676+
line?: number,
677+
importedSymbols?: string[]
678+
): void {
667679
const fromFile = this.normalizePath(importingFile);
668680
const toFile = this.normalizePath(importedFile);
669681

@@ -674,15 +686,43 @@ export class InternalFileGraph {
674686

675687
this.imports.get(fromFile)!.add(toFile);
676688

677-
// Track which symbols are imported from the target file
678-
if (importedSymbols && importedSymbols.length > 0) {
689+
const normalizedLine =
690+
typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined;
691+
692+
const normalizedSymbols =
693+
importedSymbols && importedSymbols.length > 0
694+
? importedSymbols.filter((sym) => sym !== '*' && sym !== 'default')
695+
: [];
696+
697+
if (normalizedLine || normalizedSymbols.length > 0) {
698+
if (!this.importDetails.has(fromFile)) {
699+
this.importDetails.set(fromFile, new Map());
700+
}
701+
const detailsForFrom = this.importDetails.get(fromFile)!;
702+
const existing = detailsForFrom.get(toFile) ?? {};
703+
704+
const mergedLine =
705+
existing.line && normalizedLine
706+
? Math.min(existing.line, normalizedLine)
707+
: (existing.line ?? normalizedLine);
708+
709+
const mergedSymbolsSet = new Set<string>(existing.importedSymbols ?? []);
710+
for (const sym of normalizedSymbols) mergedSymbolsSet.add(sym);
711+
const mergedSymbols = Array.from(mergedSymbolsSet);
712+
713+
detailsForFrom.set(toFile, {
714+
...(mergedLine ? { line: mergedLine } : {}),
715+
...(mergedSymbols.length > 0 ? { importedSymbols: mergedSymbols.sort() } : {})
716+
});
717+
}
718+
719+
// Track which symbols are imported from the target file (for unused export detection)
720+
if (normalizedSymbols.length > 0) {
679721
if (!this.importedSymbols.has(toFile)) {
680722
this.importedSymbols.set(toFile, new Set());
681723
}
682-
for (const sym of importedSymbols) {
683-
if (sym !== '*' && sym !== 'default') {
684-
this.importedSymbols.get(toFile)!.add(sym);
685-
}
724+
for (const sym of normalizedSymbols) {
725+
this.importedSymbols.get(toFile)!.add(sym);
686726
}
687727
}
688728
}
@@ -824,6 +864,7 @@ export class InternalFileGraph {
824864
toJSON(): {
825865
imports: Record<string, string[]>;
826866
exports: Record<string, FileExport[]>;
867+
importDetails?: Record<string, Record<string, ImportEdgeDetail>>;
827868
stats: { files: number; edges: number; avgDependencies: number };
828869
} {
829870
const imports: Record<string, string[]> = {};
@@ -836,7 +877,25 @@ export class InternalFileGraph {
836877
exports[file] = exps;
837878
}
838879

839-
return { imports, exports, stats: this.getStats() };
880+
const importDetails: Record<string, Record<string, ImportEdgeDetail>> = {};
881+
for (const [fromFile, edges] of this.importDetails.entries()) {
882+
const nested: Record<string, ImportEdgeDetail> = {};
883+
for (const [toFile, detail] of edges.entries()) {
884+
if (!detail.line && (!detail.importedSymbols || detail.importedSymbols.length === 0))
885+
continue;
886+
nested[toFile] = detail;
887+
}
888+
if (Object.keys(nested).length > 0) {
889+
importDetails[fromFile] = nested;
890+
}
891+
}
892+
893+
return {
894+
imports,
895+
exports,
896+
...(Object.keys(importDetails).length > 0 ? { importDetails } : {}),
897+
stats: this.getStats()
898+
};
840899
}
841900

842901
/**
@@ -846,6 +905,7 @@ export class InternalFileGraph {
846905
data: {
847906
imports?: Record<string, string[]>;
848907
exports?: Record<string, FileExport[]>;
908+
importDetails?: Record<string, Record<string, ImportEdgeDetail>>;
849909
},
850910
rootPath: string
851911
): InternalFileGraph {
@@ -863,6 +923,27 @@ export class InternalFileGraph {
863923
}
864924
}
865925

926+
if (data.importDetails) {
927+
for (const [fromFile, edges] of Object.entries(data.importDetails)) {
928+
const edgeMap = new Map<string, ImportEdgeDetail>();
929+
for (const [toFile, detail] of Object.entries(edges ?? {})) {
930+
edgeMap.set(toFile, detail);
931+
932+
if (detail.importedSymbols && detail.importedSymbols.length > 0) {
933+
if (!graph.importedSymbols.has(toFile)) {
934+
graph.importedSymbols.set(toFile, new Set());
935+
}
936+
for (const sym of detail.importedSymbols) {
937+
graph.importedSymbols.get(toFile)!.add(sym);
938+
}
939+
}
940+
}
941+
if (edgeMap.size > 0) {
942+
graph.importDetails.set(fromFile, edgeMap);
943+
}
944+
}
945+
}
946+
866947
return graph;
867948
}
868949
}

0 commit comments

Comments
 (0)