Skip to content
Closed
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
5 changes: 5 additions & 0 deletions crates/tracedecay-graph-query/src/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ impl<'a> GraphQueryManager<'a> {
.iter()
.map(|symbol| symbol.occurrence.clone())
.collect::<Vec<_>>();
// 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(
Expand Down
69 changes: 8 additions & 61 deletions crates/tracedecay-mcp/src/handlers/git/affected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@ mod tests {

struct FakeAffectedTestDependents {
dependents: HashMap<String, Vec<String>>,
frontiers: std::sync::Mutex<Vec<Vec<String>>>,
}

impl AffectedTestDependents for FakeAffectedTestDependents {
Expand All @@ -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| {
Expand Down Expand Up @@ -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<String> {
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,
Expand All @@ -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] {
Expand All @@ -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::<HashSet<_>>();
assert_eq!(serial_set, expected_set);
assert_eq!(batched_set, serial_set);
ranked_runs.push(rank_affected_tests(&traversal.test_distances));
}

Expand Down
102 changes: 88 additions & 14 deletions crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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")]
Expand Down
Loading