Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion ast/src/lang/graphs/graph_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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()
Expand All @@ -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,
)
Expand Down
40 changes: 40 additions & 0 deletions ast/src/lang/graphs/neo4j/operations/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,29 @@ impl Neo4jGraph {
Ok(incoming)
}

pub async fn get_incoming_edge_source_files(
&self,
file_paths: &[String],
) -> Result<Vec<String>> {
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::<String>("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);
Expand Down Expand Up @@ -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<neo4rs::BoltType> = 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.
Expand Down
206 changes: 206 additions & 0 deletions standalone/tests/cross_repo_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ fn linked_requests(g: &mut GraphOps) -> BTreeSet<String> {
.collect()
}

fn function_calls(g: &mut GraphOps) -> BTreeSet<String> {
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<String> {
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<String> {
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<String> {
items.iter().map(|s| s.to_string()).collect()
}
Expand All @@ -123,6 +155,70 @@ fn baseline_edges() -> BTreeSet<String> {
])
}

fn baseline_function_calls() -> BTreeSet<String> {
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<String> {
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<String> {
expected(&[
"TestListBounties -> ListBounties",
"TestLoginRejectsBadBody -> Login",
])
}

fn expected_test_endpoint_calls() -> BTreeSet<String> {
expected(&[
"TestListBounties -> /bounties",
"TestLoginRejectsBadBody -> /auth/login",
])
}

fn unmodified_caller_probes() -> [&'static str; 2] {
["main -> NewRouter", "Login -> writeJSON"]
}

fn evolved_edges() -> BTreeSet<String> {
expected(&[
"GET /bounties",
Expand Down Expand Up @@ -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();
Expand Down
Loading