33 *
44 * Reuses pipeline helpers instead of duplicating node insertion and edge building
55 * logic from the main builder. This eliminates the watcher.js divergence (ROADMAP 3.9).
6+ *
7+ * Reverse-dep cascade: when a file changes, files that have edges targeting it
8+ * must have their outgoing edges rebuilt (since the changed file's node IDs change).
69 */
710import fs from 'node:fs' ;
811import path from 'node:path' ;
12+ import { bulkNodeIdsByFile } from '../../../db/index.js' ;
913import { warn } from '../../../infrastructure/logger.js' ;
1014import { normalizePath } from '../../../shared/constants.js' ;
1115import { parseFileIncremental } from '../../parser.js' ;
@@ -18,15 +22,204 @@ function insertFileNodes(stmts, relPath, symbols) {
1822 stmts . insertNode . run ( relPath , 'file' , relPath , 0 , null ) ;
1923 for ( const def of symbols . definitions ) {
2024 stmts . insertNode . run ( def . name , def . kind , relPath , def . line , def . endLine || null ) ;
25+ if ( def . children ?. length ) {
26+ for ( const child of def . children ) {
27+ stmts . insertNode . run (
28+ child . name ,
29+ child . kind ,
30+ relPath ,
31+ child . line ,
32+ child . endLine || null ,
33+ ) ;
34+ }
35+ }
2136 }
2237 for ( const exp of symbols . exports ) {
2338 stmts . insertNode . run ( exp . name , exp . kind , relPath , exp . line , null ) ;
2439 }
2540}
2641
42+ // ── Containment edges ──────────────────────────────────────────────────
43+
44+ function buildContainmentEdges ( db , stmts , relPath , symbols ) {
45+ const nodeIdMap = new Map ( ) ;
46+ for ( const row of bulkNodeIdsByFile ( db , relPath ) ) {
47+ nodeIdMap . set ( `${ row . name } |${ row . kind } |${ row . line } ` , row . id ) ;
48+ }
49+ const fileId = nodeIdMap . get ( `${ relPath } |file|0` ) ;
50+ let edgesAdded = 0 ;
51+ for ( const def of symbols . definitions ) {
52+ const defId = nodeIdMap . get ( `${ def . name } |${ def . kind } |${ def . line } ` ) ;
53+ if ( fileId && defId ) {
54+ stmts . insertEdge . run ( fileId , defId , 'contains' , 1.0 , 0 ) ;
55+ edgesAdded ++ ;
56+ }
57+ if ( def . children ?. length && defId ) {
58+ for ( const child of def . children ) {
59+ const childId = nodeIdMap . get ( `${ child . name } |${ child . kind } |${ child . line } ` ) ;
60+ if ( childId ) {
61+ stmts . insertEdge . run ( defId , childId , 'contains' , 1.0 , 0 ) ;
62+ edgesAdded ++ ;
63+ if ( child . kind === 'parameter' ) {
64+ stmts . insertEdge . run ( childId , defId , 'parameter_of' , 1.0 , 0 ) ;
65+ edgesAdded ++ ;
66+ }
67+ }
68+ }
69+ }
70+ }
71+ return edgesAdded ;
72+ }
73+
74+ // ── Reverse-dep cascade ────────────────────────────────────────────────
75+
76+ function findReverseDeps ( db , relPath ) {
77+ return db
78+ . prepare (
79+ `SELECT DISTINCT n_src.file FROM edges e
80+ JOIN nodes n_src ON e.source_id = n_src.id
81+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
82+ WHERE n_tgt.file = ? AND n_src.file != ? AND n_src.kind != 'directory'` ,
83+ )
84+ . all ( relPath , relPath )
85+ . map ( ( r ) => r . file ) ;
86+ }
87+
88+ function deleteOutgoingEdges ( db , relPath ) {
89+ db . prepare ( 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)' ) . run (
90+ relPath ,
91+ ) ;
92+ }
93+
94+ async function parseReverseDep ( rootDir , depRelPath , engineOpts , cache ) {
95+ const absPath = path . join ( rootDir , depRelPath ) ;
96+ if ( ! fs . existsSync ( absPath ) ) return null ;
97+
98+ let code ;
99+ try {
100+ code = readFileSafe ( absPath ) ;
101+ } catch {
102+ return null ;
103+ }
104+
105+ return parseFileIncremental ( cache , absPath , code , engineOpts ) ;
106+ }
107+
108+ function rebuildReverseDepEdges ( db , rootDir , depRelPath , symbols , stmts , skipBarrel ) {
109+ const fileNodeRow = stmts . getNodeId . get ( depRelPath , 'file' , depRelPath , 0 ) ;
110+ if ( ! fileNodeRow ) return 0 ;
111+
112+ const aliases = { baseUrl : null , paths : { } } ;
113+ let edgesAdded = buildContainmentEdges ( db , stmts , depRelPath , symbols ) ;
114+ // Don't rebuild dir→file containment for reverse-deps (it was never deleted)
115+ edgesAdded += buildImportEdges (
116+ stmts ,
117+ depRelPath ,
118+ symbols ,
119+ rootDir ,
120+ fileNodeRow . id ,
121+ aliases ,
122+ skipBarrel ? null : db ,
123+ ) ;
124+ const importedNames = buildImportedNamesMap ( symbols , rootDir , depRelPath , aliases ) ;
125+ edgesAdded += buildCallEdges ( stmts , depRelPath , symbols , fileNodeRow , importedNames ) ;
126+ return edgesAdded ;
127+ }
128+
129+ // ── Directory containment edges ────────────────────────────────────────
130+
131+ function rebuildDirContainment ( db , stmts , relPath ) {
132+ const dir = normalizePath ( path . dirname ( relPath ) ) ;
133+ if ( ! dir || dir === '.' ) return 0 ;
134+ const dirRow = stmts . getNodeId . get ( dir , 'directory' , dir , 0 ) ;
135+ const fileRow = stmts . getNodeId . get ( relPath , 'file' , relPath , 0 ) ;
136+ if ( dirRow && fileRow ) {
137+ stmts . insertEdge . run ( dirRow . id , fileRow . id , 'contains' , 1.0 , 0 ) ;
138+ return 1 ;
139+ }
140+ return 0 ;
141+ }
142+
143+ // ── Ancillary table cleanup ────────────────────────────────────────────
144+
145+ function purgeAncillaryData ( db , relPath ) {
146+ const tryExec = ( sql , ...args ) => {
147+ try {
148+ db . prepare ( sql ) . run ( ...args ) ;
149+ } catch {
150+ /* table may not exist */
151+ }
152+ } ;
153+ tryExec (
154+ 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)' ,
155+ relPath ,
156+ ) ;
157+ tryExec (
158+ 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)' ,
159+ relPath ,
160+ ) ;
161+ tryExec (
162+ 'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)' ,
163+ relPath ,
164+ ) ;
165+ tryExec (
166+ 'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)' ,
167+ relPath ,
168+ ) ;
169+ tryExec (
170+ 'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)' ,
171+ relPath ,
172+ relPath ,
173+ ) ;
174+ tryExec ( 'DELETE FROM ast_nodes WHERE file = ?' , relPath ) ;
175+ }
176+
27177// ── Import edge building ────────────────────────────────────────────────
28178
29- function buildImportEdges ( stmts , relPath , symbols , rootDir , fileNodeId , aliases ) {
179+ function isBarrelFile ( db , relPath ) {
180+ const reexportCount = db
181+ . prepare (
182+ `SELECT COUNT(*) as c FROM edges e
183+ JOIN nodes n ON e.source_id = n.id
184+ WHERE e.kind = 'reexports' AND n.file = ? AND n.kind = 'file'` ,
185+ )
186+ . get ( relPath ) ?. c ;
187+ return ( reexportCount || 0 ) > 0 ;
188+ }
189+
190+ function resolveBarrelTarget ( db , barrelPath , symbolName , visited = new Set ( ) ) {
191+ if ( visited . has ( barrelPath ) ) return null ;
192+ visited . add ( barrelPath ) ;
193+
194+ // Find re-export targets from this barrel
195+ const reexportTargets = db
196+ . prepare (
197+ `SELECT DISTINCT n2.file FROM edges e
198+ JOIN nodes n1 ON e.source_id = n1.id
199+ JOIN nodes n2 ON e.target_id = n2.id
200+ WHERE e.kind = 'reexports' AND n1.file = ? AND n1.kind = 'file'` ,
201+ )
202+ . all ( barrelPath ) ;
203+
204+ for ( const { file : targetFile } of reexportTargets ) {
205+ // Check if the symbol is defined in this target file
206+ const hasDef = db
207+ . prepare (
208+ `SELECT 1 FROM nodes WHERE name = ? AND file = ? AND kind != 'file' AND kind != 'directory' LIMIT 1` ,
209+ )
210+ . get ( symbolName , targetFile ) ;
211+ if ( hasDef ) return targetFile ;
212+
213+ // Recurse through barrel chains
214+ if ( isBarrelFile ( db , targetFile ) ) {
215+ const deeper = resolveBarrelTarget ( db , targetFile , symbolName , visited ) ;
216+ if ( deeper ) return deeper ;
217+ }
218+ }
219+ return null ;
220+ }
221+
222+ function buildImportEdges ( stmts , relPath , symbols , rootDir , fileNodeId , aliases , db ) {
30223 let edgesAdded = 0 ;
31224 for ( const imp of symbols . imports ) {
32225 const resolvedPath = resolveImportPath (
@@ -40,6 +233,24 @@ function buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeId, aliases)
40233 const edgeKind = imp . reexport ? 'reexports' : imp . typeOnly ? 'imports-type' : 'imports' ;
41234 stmts . insertEdge . run ( fileNodeId , targetRow . id , edgeKind , 1.0 , 0 ) ;
42235 edgesAdded ++ ;
236+
237+ // Barrel resolution: create edges through re-export chains
238+ if ( ! imp . reexport && db && isBarrelFile ( db , resolvedPath ) ) {
239+ const resolvedSources = new Set ( ) ;
240+ for ( const name of imp . names ) {
241+ const cleanName = name . replace ( / ^ \* \s + a s \s + / , '' ) ;
242+ const actualSource = resolveBarrelTarget ( db , resolvedPath , cleanName ) ;
243+ if ( actualSource && actualSource !== resolvedPath && ! resolvedSources . has ( actualSource ) ) {
244+ resolvedSources . add ( actualSource ) ;
245+ const actualRow = stmts . getNodeId . get ( actualSource , 'file' , actualSource , 0 ) ;
246+ if ( actualRow ) {
247+ const kind = edgeKind === 'imports-type' ? 'imports-type' : 'imports' ;
248+ stmts . insertEdge . run ( fileNodeId , actualRow . id , kind , 0.9 , 0 ) ;
249+ edgesAdded ++ ;
250+ }
251+ }
252+ }
253+ }
43254 }
44255 }
45256 return edgesAdded ;
@@ -156,12 +367,17 @@ function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) {
156367 * @param {Function } [options.diffSymbols] - Symbol diff function
157368 * @returns {Promise<object|null> } Update result or null on failure
158369 */
159- export async function rebuildFile ( _db , rootDir , filePath , stmts , engineOpts , cache , options = { } ) {
370+ export async function rebuildFile ( db , rootDir , filePath , stmts , engineOpts , cache , options = { } ) {
160371 const { diffSymbols } = options ;
161372 const relPath = normalizePath ( path . relative ( rootDir , filePath ) ) ;
162373 const oldNodes = stmts . countNodes . get ( relPath ) ?. c || 0 ;
163374 const oldSymbols = diffSymbols ? stmts . listSymbols . all ( relPath ) : [ ] ;
164375
376+ // Find reverse-deps BEFORE purging (edges still reference the old nodes)
377+ const reverseDeps = findReverseDeps ( db , relPath ) ;
378+
379+ // Purge ancillary tables, then edges, then nodes
380+ purgeAncillaryData ( db , relPath ) ;
165381 stmts . deleteEdgesForFile . run ( relPath ) ;
166382 stmts . deleteNodes . run ( relPath ) ;
167383
@@ -203,10 +419,61 @@ export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cac
203419
204420 const aliases = { baseUrl : null , paths : { } } ;
205421
206- let edgesAdded = buildImportEdges ( stmts , relPath , symbols , rootDir , fileNodeRow . id , aliases ) ;
422+ let edgesAdded = buildContainmentEdges ( db , stmts , relPath , symbols ) ;
423+ edgesAdded += rebuildDirContainment ( db , stmts , relPath ) ;
424+ edgesAdded += buildImportEdges ( stmts , relPath , symbols , rootDir , fileNodeRow . id , aliases , db ) ;
207425 const importedNames = buildImportedNamesMap ( symbols , rootDir , relPath , aliases ) ;
208426 edgesAdded += buildCallEdges ( stmts , relPath , symbols , fileNodeRow , importedNames ) ;
209427
428+ // Cascade: rebuild outgoing edges for reverse-dep files.
429+ // Two-pass approach: first rebuild direct edges (creating reexports edges for barrels),
430+ // then add barrel import edges (which need reexports edges to exist for resolution).
431+ const depSymbols = new Map ( ) ;
432+ for ( const depRelPath of reverseDeps ) {
433+ deleteOutgoingEdges ( db , depRelPath ) ;
434+ const symbols_ = await parseReverseDep ( rootDir , depRelPath , engineOpts , cache ) ;
435+ if ( symbols_ ) depSymbols . set ( depRelPath , symbols_ ) ;
436+ }
437+ // Pass 1: direct edges only (no barrel resolution) — creates reexports edges
438+ for ( const [ depRelPath , symbols_ ] of depSymbols ) {
439+ edgesAdded += rebuildReverseDepEdges ( db , rootDir , depRelPath , symbols_ , stmts , true ) ;
440+ }
441+ // Pass 2: add barrel import edges (reexports edges now exist)
442+ for ( const [ depRelPath , symbols_ ] of depSymbols ) {
443+ const fileNodeRow_ = stmts . getNodeId . get ( depRelPath , 'file' , depRelPath , 0 ) ;
444+ if ( ! fileNodeRow_ ) continue ;
445+ const aliases_ = { baseUrl : null , paths : { } } ;
446+ for ( const imp of symbols_ . imports ) {
447+ if ( imp . reexport ) continue ;
448+ const resolvedPath = resolveImportPath (
449+ path . join ( rootDir , depRelPath ) ,
450+ imp . source ,
451+ rootDir ,
452+ aliases_ ,
453+ ) ;
454+ if ( db && isBarrelFile ( db , resolvedPath ) ) {
455+ const resolvedSources = new Set ( ) ;
456+ for ( const name of imp . names ) {
457+ const cleanName = name . replace ( / ^ \* \s + a s \s + / , '' ) ;
458+ const actualSource = resolveBarrelTarget ( db , resolvedPath , cleanName ) ;
459+ if (
460+ actualSource &&
461+ actualSource !== resolvedPath &&
462+ ! resolvedSources . has ( actualSource )
463+ ) {
464+ resolvedSources . add ( actualSource ) ;
465+ const actualRow = stmts . getNodeId . get ( actualSource , 'file' , actualSource , 0 ) ;
466+ if ( actualRow ) {
467+ const kind = imp . typeOnly ? 'imports-type' : 'imports' ;
468+ stmts . insertEdge . run ( fileNodeRow_ . id , actualRow . id , kind , 0.9 , 0 ) ;
469+ edgesAdded ++ ;
470+ }
471+ }
472+ }
473+ }
474+ }
475+ }
476+
210477 const symbolDiff = diffSymbols ? diffSymbols ( oldSymbols , newSymbols ) : null ;
211478 const event = oldNodes === 0 ? 'added' : 'modified' ;
212479
0 commit comments