diff --git a/ast/src/lang/graphs/graph_ops.rs b/ast/src/lang/graphs/graph_ops.rs index 594be5f2a..033cd7e74 100644 --- a/ast/src/lang/graphs/graph_ops.rs +++ b/ast/src/lang/graphs/graph_ops.rs @@ -142,6 +142,15 @@ impl GraphOps { let muted_nodes = self.collect_muted_nodes_for_files(&absolute_files).await?; + let root_prefix = format!("{}/", graph_root); + let caller_files: Vec = self + .graph + .get_incoming_edge_source_files(&absolute_files) + .await? + .iter() + .filter_map(|f| f.strip_prefix(&root_prefix).map(str::to_string)) + .collect(); + info!( "[incremental] removing existing nodes for {} modified file(s)", modified_files.len() @@ -156,10 +165,19 @@ impl GraphOps { modified_files.len() ); + let mut parse_files = modified_files; + if !caller_files.is_empty() { + info!( + "[incremental] re-parsing {} caller file(s) with edges into modified files", + caller_files.len() + ); + parse_files.extend(caller_files); + } + let mut subgraph_repos = Repo::new_multi_detect( &repo_path, Some(repo_url.to_string()), - modified_files, + parse_files, vec![stored_hash.to_string(), current_hash.to_string()], use_lsp, ) diff --git a/ast/src/lang/graphs/neo4j/operations/migration.rs b/ast/src/lang/graphs/neo4j/operations/migration.rs index 1003819b4..595bea5c0 100644 --- a/ast/src/lang/graphs/neo4j/operations/migration.rs +++ b/ast/src/lang/graphs/neo4j/operations/migration.rs @@ -159,6 +159,29 @@ impl Neo4jGraph { Ok(incoming) } + pub async fn get_incoming_edge_source_files( + &self, + file_paths: &[String], + ) -> Result> { + if file_paths.is_empty() { + return Ok(Vec::new()); + } + let connection = self.ensure_connected().await?; + let (query_str, params) = incoming_edge_source_files_query(file_paths); + let mut query_obj = query(&query_str); + for (key, value) in params.value.iter() { + query_obj = query_obj.param(key.value.as_str(), value.clone()); + } + let mut result = connection.execute(query_obj).await?; + let mut files = Vec::new(); + while let Some(row) = result.next().await? { + if let Ok(file) = row.get::("file") { + files.push(file); + } + } + Ok(files) + } + pub async fn clear_existing_graph(&self, root: &str) -> Result<()> { let connection = self.ensure_connected().await?; info!("Clearing existing graph for root: {}", root); @@ -316,6 +339,23 @@ pub fn get_repository_hash_query(repo_url: &str) -> (String, BoltMap) { (query.to_string(), params) } +pub fn incoming_edge_source_files_query(file_paths: &[String]) -> (String, BoltMap) { + let mut params = BoltMap::new(); + let files: Vec = file_paths + .iter() + .map(|p| neo4rs::BoltType::String(p.clone().into())) + .collect(); + boltmap_insert_list(&mut params, "files", files); + + let query = " + MATCH (src:Data_Bank)-[r]->(dst:Data_Bank) + WHERE dst.file IN $files AND NOT src.file IN $files AND type(r) <> 'CONTAINS' + RETURN DISTINCT src.file as file + "; + + (query.to_string(), params) +} + /// Single-statement detach-delete. Kept for callers that explicitly want the /// all-in-one-transaction semantics; the incremental sync path prefers the /// chunked variant below to avoid massive lock sets. diff --git a/standalone/tests/cross_repo_sync.rs b/standalone/tests/cross_repo_sync.rs index b5732c022..598a0735a 100644 --- a/standalone/tests/cross_repo_sync.rs +++ b/standalone/tests/cross_repo_sync.rs @@ -99,6 +99,38 @@ fn linked_requests(g: &mut GraphOps) -> BTreeSet { .collect() } +fn function_calls(g: &mut GraphOps) -> BTreeSet { + g.graph + .find_nodes_with_edge_type(NodeType::Function, NodeType::Function, EdgeType::Calls) + .iter() + .map(|(src, dst)| format!("{} -> {}", src.name, dst.name)) + .collect() +} + +fn integration_test_calls(g: &mut GraphOps) -> BTreeSet { + g.graph + .find_nodes_with_edge_type( + NodeType::IntegrationTest, + NodeType::Function, + EdgeType::Calls, + ) + .iter() + .map(|(src, dst)| format!("{} -> {}", src.name, dst.name)) + .collect() +} + +fn integration_test_endpoint_calls(g: &mut GraphOps) -> BTreeSet { + g.graph + .find_nodes_with_edge_type( + NodeType::IntegrationTest, + NodeType::Endpoint, + EdgeType::Calls, + ) + .iter() + .map(|(src, dst)| format!("{} -> {}", src.name, dst.name)) + .collect() +} + fn expected(items: &[&str]) -> BTreeSet { items.iter().map(|s| s.to_string()).collect() } @@ -123,6 +155,70 @@ fn baseline_edges() -> BTreeSet { ]) } +fn baseline_function_calls() -> BTreeSet { + expected(&[ + "main -> NewRouter", + "Login -> writeJSON", + "ListBounties -> AllBounties", + "ListBounties -> writeJSON", + "CreateBounty -> SaveBounty", + "CreateBounty -> writeJSON", + "GetBounty -> FindBounty", + "GetBounty -> writeJSON", + "UpdateBounty -> SaveBounty", + "UpdateBounty -> writeJSON", + "DeleteBounty -> RemoveBounty", + "ListPeople -> AllPeople", + "ListPeople -> writeJSON", + "GetPerson -> FindPerson", + "GetPerson -> writeJSON", + "GetWorkspace -> writeJSON", + ]) +} + +fn evolved_function_calls() -> BTreeSet { + expected(&[ + "main -> NewRouter", + "Login -> writeJSON", + "ListBounties -> AllBounties", + "ListBounties -> writeJSON", + "CreateBounty -> SaveBounty", + "CreateBounty -> writeJSON", + "GetBounty -> FindBounty", + "GetBounty -> writeJSON", + "UpdateBounty -> SaveBounty", + "UpdateBounty -> writeJSON", + "AssignBounty -> FindBounty", + "AssignBounty -> SaveBounty", + "AssignBounty -> writeJSON", + "ListUsers -> AllPeople", + "ListUsers -> writeJSON", + "GetUser -> FindPerson", + "GetUser -> writeJSON", + "GetWorkspace -> writeJSON", + "ListWorkspaceBounties -> AllBounties", + "ListWorkspaceBounties -> writeJSON", + ]) +} + +fn expected_test_calls() -> BTreeSet { + expected(&[ + "TestListBounties -> ListBounties", + "TestLoginRejectsBadBody -> Login", + ]) +} + +fn expected_test_endpoint_calls() -> BTreeSet { + expected(&[ + "TestListBounties -> /bounties", + "TestLoginRejectsBadBody -> /auth/login", + ]) +} + +fn unmodified_caller_probes() -> [&'static str; 2] { + ["main -> NewRouter", "Login -> writeJSON"] +} + fn evolved_edges() -> BTreeSet { expected(&[ "GET /bounties", @@ -208,6 +304,116 @@ async fn test_backward_sync_full_reindex() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sync_preserves_calls_from_unmodified_files() { + let state = test_state(); + let mut g = fresh_graph().await; + + reset_clones(); + let _ = ingest(State(state.clone()), Json(body(BE, Some("before")))) + .await + .expect("backend ingest failed"); + + let baseline = function_calls(&mut g); + for probe in unmodified_caller_probes() { + assert!( + baseline.contains(probe), + "baseline missing call edge {probe}; full set: {baseline:?}" + ); + } + assert_eq!( + baseline, + baseline_function_calls(), + "baseline function call graph" + ); + assert_eq!( + integration_test_calls(&mut g), + expected_test_calls(), + "baseline test-to-function call edges" + ); + assert_eq!( + integration_test_endpoint_calls(&mut g), + expected_test_endpoint_calls(), + "baseline test-to-endpoint call edges" + ); + let baseline_pairs = g.graph.find_nodes_with_edge_type( + NodeType::Function, + NodeType::Function, + EdgeType::Calls, + ); + assert_eq!( + baseline_pairs.len(), + baseline.len(), + "duplicate baseline function call edges" + ); + + let auth_file = "fayekelmith/graph-update-backend/handlers/auth.go"; + let muted = g + .set_node_muted(&NodeType::Function, "Login", auth_file, true) + .await + .unwrap_or(0); + assert!(muted > 0, "expected to mute the Login function node"); + + sync_repo(&state, BE, "after").await; + + let evolved = function_calls(&mut g); + for probe in unmodified_caller_probes() { + assert!( + evolved.contains(probe), + "call edge from unmodified file lost after sync: {probe}" + ); + } + assert!( + !evolved.contains("DeleteBounty -> RemoveBounty"), + "call edge from deleted function survived sync" + ); + assert!( + !evolved.contains("ListPeople -> AllPeople"), + "call edge from renamed-away function survived sync" + ); + assert_eq!( + evolved, + evolved_function_calls(), + "evolved function call graph" + ); + assert_eq!( + integration_test_calls(&mut g), + expected_test_calls(), + "test-to-function call edges from unmodified test file lost after sync" + ); + assert_eq!( + integration_test_endpoint_calls(&mut g), + expected_test_endpoint_calls(), + "test-to-endpoint call edges from unmodified test file lost after sync" + ); + let evolved_pairs = g.graph.find_nodes_with_edge_type( + NodeType::Function, + NodeType::Function, + EdgeType::Calls, + ); + assert_eq!( + evolved_pairs.len(), + evolved.len(), + "duplicate function call edges after sync" + ); + let test_pairs = g.graph.find_nodes_with_edge_type( + NodeType::IntegrationTest, + NodeType::Function, + EdgeType::Calls, + ); + assert_eq!( + test_pairs.len(), + expected_test_calls().len(), + "duplicate test call edges after sync" + ); + assert!( + g.is_node_muted(&NodeType::Function, "Login", auth_file) + .await + .unwrap_or(false), + "mute on re-parsed caller file lost after sync" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_cross_repo_muted_preservation() { let state = test_state();