@@ -27,6 +27,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
2727interface 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 ;
0 commit comments