Skip to content

Commit 562ce84

Browse files
authored
Merge branch 'main' into fix/1176-watch-fk-embeddings
2 parents d2db316 + 16840aa commit 562ce84

12 files changed

Lines changed: 405 additions & 83 deletions

File tree

crates/codegraph-core/src/build_pipeline.rs

Lines changed: 119 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
603613
fn 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.

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -770,9 +770,11 @@ function reconnectReverseDepEdges(ctx: PipelineContext): void {
770770
* their import targets. Falls back to loading ALL nodes for full builds or
771771
* larger incremental changes.
772772
*/
773+
const NODE_KIND_FILTER_SQL = `kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant')`;
774+
773775
function loadNodes(ctx: PipelineContext): { rows: QueryNodeRow[]; scoped: boolean } {
774776
const { db, fileSymbols, isFullBuild, batchResolved } = ctx;
775-
const nodeKindFilter = `kind IN ('function','method','class','interface','struct','type','module','enum','trait','record','constant')`;
777+
const nodeKindFilter = NODE_KIND_FILTER_SQL;
776778

777779
// Gate: only scope for small incremental on large codebases
778780
if (!isFullBuild && fileSymbols.size <= ctx.config.build.smallFilesThreshold) {
@@ -816,8 +818,13 @@ function loadNodes(ctx: PipelineContext): { rows: QueryNodeRow[]; scoped: boolea
816818
function addLazyFallback(ctx: PipelineContext, scopedLoad: boolean): void {
817819
if (!scopedLoad) return;
818820
const { db } = ctx;
821+
// Match the upfront kind filter exactly. Using `kind != 'file'` here lets
822+
// parameters, properties, and other non-definition kinds leak into call
823+
// resolution, producing bogus call edges like `parser.ts → <a parameter
824+
// with the same name>` (#1174 follow-up). Calls only ever target the
825+
// definition kinds, so the fallback's filter must agree with `loadNodes`.
819826
const fallbackStmt = db.prepare(
820-
`SELECT id, name, kind, file, line FROM nodes WHERE name = ? AND kind != 'file'`,
827+
`SELECT id, name, kind, file, line FROM nodes WHERE name = ? AND ${NODE_KIND_FILTER_SQL}`,
821828
);
822829
const originalGet = ctx.nodesByName.get.bind(ctx.nodesByName);
823830
ctx.nodesByName.get = (name: string) => {

0 commit comments

Comments
 (0)