diff --git a/crates/tracedecay-graph-query/src/queries.rs b/crates/tracedecay-graph-query/src/queries.rs index 5a2fb76757..c4ec96c735 100644 --- a/crates/tracedecay-graph-query/src/queries.rs +++ b/crates/tracedecay-graph-query/src/queries.rs @@ -343,6 +343,11 @@ impl<'a> GraphQueryManager<'a> { .iter() .map(|symbol| symbol.occurrence.clone()) .collect::>(); + // Adjacency refuses an empty seed list. A logical path this generation + // never published has no symbols, so it has no file neighbors. + if seeds.is_empty() { + return Ok(Vec::new()); + } let edges = hotpath::measure_block!("usecases.graph.file_neighbors.edges", { if incoming { self.reader.callers( diff --git a/crates/tracedecay-mcp/src/handlers/git/affected.rs b/crates/tracedecay-mcp/src/handlers/git/affected.rs index f5403b4ace..96fb7081a5 100644 --- a/crates/tracedecay-mcp/src/handlers/git/affected.rs +++ b/crates/tracedecay-mcp/src/handlers/git/affected.rs @@ -198,7 +198,6 @@ mod tests { struct FakeAffectedTestDependents { dependents: HashMap>, - frontiers: std::sync::Mutex>>, } impl AffectedTestDependents for FakeAffectedTestDependents { @@ -207,7 +206,6 @@ mod tests { files: &'a [String], ) -> AffectedDependentsFuture<'a> { Box::pin(async move { - self.frontiers.lock().unwrap().push(files.to_vec()); Ok(files .iter() .map(|file| { @@ -244,46 +242,11 @@ mod tests { vec!["tests/transitive_test.rs".to_string()], ), ]), - frontiers: std::sync::Mutex::new(Vec::new()), } } - fn serial_affected_test_set( - source: &FakeAffectedTestDependents, - files: &[String], - max_depth: usize, - ) -> HashSet { - let mut affected = HashSet::new(); - let mut visited = HashSet::new(); - let mut queue = std::collections::VecDeque::new(); - for file in files { - if tracedecay_code_index::is_test_file(file) { - affected.insert(file.clone()); - } - if visited.insert(file.clone()) { - queue.push_back((file.clone(), 0)); - } - } - while let Some((file, depth)) = queue.pop_front() { - if depth >= max_depth { - continue; - } - for dependent in source.dependents.get(&file).into_iter().flatten() { - if !visited.insert(dependent.clone()) { - continue; - } - if tracedecay_code_index::is_test_file(dependent) { - affected.insert(dependent.clone()); - } else { - queue.push_back((dependent.clone(), depth + 1)); - } - } - } - affected - } - #[tokio::test] - async fn affected_traversal_batches_one_database_read_per_frontier() { + async fn affected_traversal_records_minimum_hop_to_each_test() { let source = fake_affected_test_dependents(false); let traversal = collect_affected_test_files( &source, @@ -296,25 +259,17 @@ mod tests { .unwrap(); assert_eq!( - *source.frontiers.lock().unwrap(), - vec![ - vec!["src/root.rs".to_string()], - vec!["src/a.rs".to_string(), "src/b.rs".to_string()], - vec!["src/leaf.rs".to_string()], - ] + traversal.test_distances, + HashMap::from([ + ("tests/direct_test.rs".to_string(), 1), + ("tests/near_test.rs".to_string(), 2), + ("tests/transitive_test.rs".to_string(), 3), + ]) ); - assert_eq!(source.frontiers.lock().unwrap().len(), 3); - assert_eq!(traversal.test_distances.len(), 3); } #[tokio::test] - async fn affected_traversal_preserves_set_parity_and_ranks_deterministically() { - let expected_set = HashSet::from([ - "tests/changed_test.rs".to_string(), - "tests/direct_test.rs".to_string(), - "tests/near_test.rs".to_string(), - "tests/transitive_test.rs".to_string(), - ]); + async fn affected_traversal_ranks_changed_tests_first_regardless_of_neighbor_order() { let mut ranked_runs = Vec::new(); for reverse in [false, true] { @@ -323,17 +278,9 @@ mod tests { "tests/changed_test.rs".to_string(), "src/root.rs".to_string(), ]; - let serial_set = serial_affected_test_set(&source, &files, 5); let traversal = collect_affected_test_files(&source, &files, 5, None, &HashSet::new()) .await .unwrap(); - let batched_set = traversal - .test_distances - .keys() - .cloned() - .collect::>(); - assert_eq!(serial_set, expected_set); - assert_eq!(batched_set, serial_set); ranked_runs.push(rank_affected_tests(&traversal.test_distances)); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs index 95bd2488a5..4b0a835ee3 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs @@ -922,23 +922,15 @@ async fn test_files_json_format_with_grouped_layout() { } #[tokio::test] -async fn test_affected() { +async fn test_affected_rejects_a_call_without_files() { let (cg, _dir) = production_graph_query_fixture().await; - let result = call_production_tool( - &cg, - "tracedecay_affected", - json!({"files": ["src/utils.rs"]}), - None, - None, - ) - .await - .unwrap(); - let text = extract_text(&result.value); + let error = call_production_tool(&cg, "tracedecay_affected", json!({}), None, None) + .await + .expect_err("files is required"); assert!( - text.contains("affected_tests"), - "should have affected_tests key" + error.to_string().contains("missing required parameter: files"), + "{error}" ); - assert!(text.contains("count"), "should have count key"); } #[cfg(feature = "test-transport")] @@ -1069,6 +1061,88 @@ async fn affected_central_daemon_fixture_preserves_set_and_ranks_near_tests_over payload["ranking_metadata"]["strategy"], "dependency_distance_then_path" ); + + let depth_limited = harness + .call_tool( + &project, + "tracedecay_affected", + json!({"files": ["src/daemon.rs"], "depth": 1, "format": "json"}), + ) + .await + .expect("production invocation succeeds") + .result + .expect("depth-limited affected call returns a result"); + let depth_payload: Value = serde_json::from_str( + depth_limited["content"][0]["text"] + .as_str() + .expect("depth-limited affected text"), + ) + .expect("depth-limited affected JSON"); + assert_eq!( + depth_payload["affected_tests"], + json!(["tests/daemon_direct_test.rs"]), + "depth 1 keeps the direct test and drops near and transitive tests: {depth_payload}" + ); + assert_eq!(depth_payload["count"], 1); + assert_eq!( + depth_payload["recommended_tests"], + json!(["tests/daemon_direct_test.rs"]) + ); + + let filtered = harness + .call_tool( + &project, + "tracedecay_affected", + json!({ + "files": ["src/daemon.rs"], + "depth": 5, + "filter": "tests/daemon_*.rs", + "format": "json" + }), + ) + .await + .expect("production invocation succeeds") + .result + .expect("filtered affected call returns a result"); + let filtered_payload: Value = serde_json::from_str( + filtered["content"][0]["text"] + .as_str() + .expect("filtered affected text"), + ) + .expect("filtered affected JSON"); + assert_eq!( + filtered_payload["affected_tests"], + json!(["tests/daemon_direct_test.rs"]), + "a custom glob drops test files that do not match: {filtered_payload}" + ); + + let unknown = harness + .call_tool( + &project, + "tracedecay_affected", + json!({"files": ["src/missing.rs"], "format": "json"}), + ) + .await + .expect("production invocation succeeds"); + let unknown_result = unknown.result.unwrap_or_else(|| { + panic!( + "unknown-file affected call returned an error: {:?}", + unknown.error + ) + }); + let unknown_payload: Value = serde_json::from_str( + unknown_result["content"][0]["text"] + .as_str() + .expect("unknown-file affected text"), + ) + .expect("unknown-file affected JSON"); + assert_eq!( + unknown_payload["affected_tests"], + json!([]), + "a path with no dependents is an empty set, not an error: {unknown_payload}" + ); + assert_eq!(unknown_payload["count"], 0); + assert_eq!(unknown_payload["recommended_tests"], json!([])); } #[cfg(feature = "test-transport")]