@@ -600,6 +600,16 @@ fn collect_source_files(
600600/// Barrel files (re-export-only index files) may not be in file_symbols because
601601/// they weren't changed or reverse-deps. Without their symbols, barrel resolution
602602/// in Stage 7 can't create transitive import edges.
603+ ///
604+ /// Discovery is iterative: a barrel that imports another barrel (e.g.
605+ /// `parser.ts → extractors/index.ts → extractors/<lang>.ts`) needs both
606+ /// loaded so Stage 7 can emit the barrel-through edges from the first barrel
607+ /// to the leaf targets. Without the loop, only the first level of barrels
608+ /// gets merged into `file_symbols`; the deeper chain has no entry in
609+ /// `reexport_map`, so `resolve_barrel_export` returns `None` and the
610+ /// barrel-through edges are silently dropped on every incremental rebuild
611+ /// (#1174). Convergence is guaranteed because `file_symbols` grows
612+ /// monotonically and is bounded by the set of barrel files in the project.
603613fn reparse_barrel_candidates (
604614 conn : & Connection ,
605615 root_dir : & str ,
@@ -624,67 +634,38 @@ fn reparse_barrel_candidates(
624634 rows. into_iter ( ) . collect ( )
625635 } ;
626636
627- // Check which barrels are imported by parsed files but not in file_symbols
628- let mut barrel_paths_to_parse: Vec < String > = Vec :: new ( ) ;
629- for ( rel_path, symbols) in file_symbols. iter ( ) {
630- for imp in & symbols. imports {
631- let abs_file = Path :: new ( root_dir) . join ( rel_path) ;
632- let fwd = abs_file. to_str ( ) . unwrap_or ( "" ) . replace ( '\\' , "/" ) ;
633- let key = format ! ( "{}|{}" , fwd, imp. source) ;
634- if let Some ( resolved) = batch_resolved. get ( & key) {
635- if barrel_files_in_db. contains ( resolved) && !file_symbols. contains_key ( resolved)
636- {
637- let abs = Path :: new ( root_dir) . join ( resolved) ;
638- if abs. exists ( ) {
639- barrel_paths_to_parse
640- . push ( abs. to_str ( ) . unwrap_or ( "" ) . to_string ( ) ) ;
641- }
642- }
643- }
644- }
645- }
646-
647- // Also find barrels that re-export FROM changed files
648- {
649- let changed_rel: Vec < & str > = file_symbols. keys ( ) . map ( |s| s. as_str ( ) ) . collect ( ) ;
650- if let Ok ( mut stmt) = conn. prepare (
651- "SELECT DISTINCT n1.file FROM edges e \
652- JOIN nodes n1 ON e.source_id = n1.id \
653- JOIN nodes n2 ON e.target_id = n2.id \
654- WHERE e.kind = 'reexports' AND n1.kind = 'file' AND n2.file = ?1",
655- ) {
656- for changed in & changed_rel {
657- if let Ok ( rows) = stmt. query_map ( rusqlite:: params![ changed] , |row| {
658- row. get :: < _ , String > ( 0 )
659- } ) {
660- for row in rows. flatten ( ) {
661- if !file_symbols. contains_key ( & row) {
662- let abs = Path :: new ( root_dir) . join ( & row) ;
663- if abs. exists ( ) {
664- barrel_paths_to_parse
665- . push ( abs. to_str ( ) . unwrap_or ( "" ) . to_string ( ) ) ;
666- }
667- }
668- }
669- }
670- }
671- }
672- }
673-
674- // Re-parse barrel files and merge into file_symbols
675- if !barrel_paths_to_parse. is_empty ( ) {
637+ // Seed: barrels imported by the initial file_symbols (= changed files),
638+ // plus barrels that re-export FROM any changed file. The reexport-from
639+ // seed only fires on the initial pass — re-parsed barrels haven't
640+ // changed in content, so they can't trigger new reexport-from candidates.
641+ let initial_files: Vec < String > = file_symbols. keys ( ) . cloned ( ) . collect ( ) ;
642+ let mut barrel_paths_to_parse: Vec < String > = collect_imported_barrel_candidates (
643+ root_dir,
644+ & initial_files,
645+ batch_resolved,
646+ & barrel_files_in_db,
647+ file_symbols,
648+ ) ;
649+ barrel_paths_to_parse. extend ( collect_reexport_from_barrels (
650+ conn,
651+ root_dir,
652+ & initial_files,
653+ file_symbols,
654+ ) ) ;
655+
656+ // Iterative re-parse: each pass merges the queued barrels into file_symbols,
657+ // then scans their imports for additional barrel candidates the previous
658+ // pass couldn't see.
659+ while !barrel_paths_to_parse. is_empty ( ) {
676660 barrel_paths_to_parse. sort ( ) ;
677661 barrel_paths_to_parse. dedup ( ) ;
662+ let to_parse = std:: mem:: take ( & mut barrel_paths_to_parse) ;
678663 // Re-parse barrel candidates — these may be hybrid barrels (reexports
679664 // AND local definitions / call sites, see #979). Dataflow/AST analysis
680665 // is skipped because the barrel is not itself a "changed" file; Stage 7
681666 // will reconstruct all outgoing edge kinds from the fresh parse.
682- let barrel_parsed = parallel:: parse_files_parallel (
683- & barrel_paths_to_parse,
684- root_dir,
685- false ,
686- false ,
687- ) ;
667+ let barrel_parsed = parallel:: parse_files_parallel ( & to_parse, root_dir, false , false ) ;
668+ let mut newly_added: Vec < String > = Vec :: with_capacity ( barrel_parsed. len ( ) ) ;
688669 for mut sym in barrel_parsed {
689670 let rel = relative_path ( root_dir, & sym. file ) ;
690671 sym. file = rel. clone ( ) ;
@@ -727,9 +708,91 @@ fn reparse_barrel_candidates(
727708 batch_resolved. insert ( key, r. resolved_path . clone ( ) ) ;
728709 }
729710 }
730- file_symbols. insert ( rel, sym) ;
711+ file_symbols. insert ( rel. clone ( ) , sym) ;
712+ newly_added. push ( rel) ;
713+ }
714+
715+ // Scan just-merged barrels for further barrel imports (next level of
716+ // the chain). batch_resolved is now up to date for these imports.
717+ barrel_paths_to_parse = collect_imported_barrel_candidates (
718+ root_dir,
719+ & newly_added,
720+ batch_resolved,
721+ & barrel_files_in_db,
722+ file_symbols,
723+ ) ;
724+ }
725+ }
726+
727+ /// Walk the imports of `from_files` and return absolute paths of any barrel
728+ /// candidates (files in `barrel_files_in_db` not yet in `file_symbols`) that
729+ /// exist on disk.
730+ fn collect_imported_barrel_candidates (
731+ root_dir : & str ,
732+ from_files : & [ String ] ,
733+ batch_resolved : & HashMap < String , String > ,
734+ barrel_files_in_db : & HashSet < String > ,
735+ file_symbols : & HashMap < String , FileSymbols > ,
736+ ) -> Vec < String > {
737+ let mut out = Vec :: new ( ) ;
738+ for rel_path in from_files {
739+ let symbols = match file_symbols. get ( rel_path) {
740+ Some ( s) => s,
741+ None => continue ,
742+ } ;
743+ let abs_file = Path :: new ( root_dir) . join ( rel_path) ;
744+ let fwd = abs_file. to_str ( ) . unwrap_or ( "" ) . replace ( '\\' , "/" ) ;
745+ for imp in & symbols. imports {
746+ let key = format ! ( "{}|{}" , fwd, imp. source) ;
747+ if let Some ( resolved) = batch_resolved. get ( & key) {
748+ if barrel_files_in_db. contains ( resolved)
749+ && !file_symbols. contains_key ( resolved)
750+ {
751+ let abs = Path :: new ( root_dir) . join ( resolved) ;
752+ if abs. exists ( ) {
753+ out. push ( abs. to_str ( ) . unwrap_or ( "" ) . to_string ( ) ) ;
754+ }
755+ }
756+ }
757+ }
758+ }
759+ out
760+ }
761+
762+ /// Find barrels that re-export from any of `changed_files`. Used as a seed
763+ /// for the iterative re-parse so a renamed/removed symbol in a changed file
764+ /// re-emits the affected barrel's outgoing edges.
765+ fn collect_reexport_from_barrels (
766+ conn : & Connection ,
767+ root_dir : & str ,
768+ changed_files : & [ String ] ,
769+ file_symbols : & HashMap < String , FileSymbols > ,
770+ ) -> Vec < String > {
771+ let mut out = Vec :: new ( ) ;
772+ let mut stmt = match conn. prepare (
773+ "SELECT DISTINCT n1.file FROM edges e \
774+ JOIN nodes n1 ON e.source_id = n1.id \
775+ JOIN nodes n2 ON e.target_id = n2.id \
776+ WHERE e.kind = 'reexports' AND n1.kind = 'file' AND n2.file = ?1",
777+ ) {
778+ Ok ( stmt) => stmt,
779+ Err ( _) => return out,
780+ } ;
781+ for changed in changed_files {
782+ if let Ok ( rows) =
783+ stmt. query_map ( rusqlite:: params![ changed] , |row| row. get :: < _ , String > ( 0 ) )
784+ {
785+ for row in rows. flatten ( ) {
786+ if !file_symbols. contains_key ( & row) {
787+ let abs = Path :: new ( root_dir) . join ( & row) ;
788+ if abs. exists ( ) {
789+ out. push ( abs. to_str ( ) . unwrap_or ( "" ) . to_string ( ) ) ;
790+ }
791+ }
792+ }
731793 }
732794 }
795+ out
733796}
734797
735798/// Stage 9: Finalize build — persist metadata, write journal, return counts.
0 commit comments