diff --git a/crates/rpg-nav/src/planner.rs b/crates/rpg-nav/src/planner.rs index 46f1293..2b13769 100644 --- a/crates/rpg-nav/src/planner.rs +++ b/crates/rpg-nav/src/planner.rs @@ -410,6 +410,39 @@ mod tests { ); } + #[test] + fn test_plan_root_scope_matches_unscoped_search() { + let graph = build_test_graph(); + let unscoped = plan_change( + &graph, + &PlanChangeRequest { + goal: "server", + scope: None, + max_entities: 10, + }, + None, + ); + let root_scoped = plan_change( + &graph, + &PlanChangeRequest { + goal: "server", + scope: Some("."), + max_entities: 10, + }, + None, + ); + + assert_eq!( + root_scoped.relevant_entities.len(), + unscoped.relevant_entities.len(), + "root scope should search the full graph" + ); + assert_eq!( + root_scoped.modification_order, unscoped.modification_order, + "root scope should preserve planning results" + ); + } + #[test] fn test_plan_dependency_ordering() { let graph = build_test_graph(); diff --git a/crates/rpg-nav/src/search.rs b/crates/rpg-nav/src/search.rs index c785deb..2178deb 100644 --- a/crates/rpg-nav/src/search.rs +++ b/crates/rpg-nav/src/search.rs @@ -562,11 +562,21 @@ fn maybe_hybrid_rerank( /// Collect entities from one or more hierarchy scopes. /// Supports comma-separated scopes per paper's `search_scopes` (list of paths). fn collect_scoped_entities(graph: &RPGraph, scope: &str) -> Vec { + if matches!(scope.trim(), "" | ".") { + return graph.entities.keys().cloned().collect(); + } + let scopes: Vec<&str> = scope.split(',').map(|s| s.trim()).collect(); let mut all_ids: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); for single_scope in scopes { + if single_scope == "." { + return graph.entities.keys().cloned().collect(); + } + if single_scope.is_empty() { + continue; + } for id in collect_single_scope(graph, single_scope) { if seen.insert(id.clone()) { all_ids.push(id); diff --git a/crates/rpg-nav/tests/search.rs b/crates/rpg-nav/tests/search.rs index bb00431..60d110b 100644 --- a/crates/rpg-nav/tests/search.rs +++ b/crates/rpg-nav/tests/search.rs @@ -360,6 +360,54 @@ fn test_multi_scope_with_invalid_segment() { assert_eq!(results[0].entity_name, "validate_token"); } +#[test] +fn test_root_scope_matches_unscoped_search() { + let graph = make_graph(); + let unscoped = search(&graph, "authentication", SearchMode::Features, None, 10); + let root_scoped = search( + &graph, + "authentication", + SearchMode::Features, + Some("."), + 10, + ); + + let unscoped_ids: Vec<&str> = unscoped.iter().map(|r| r.entity_id.as_str()).collect(); + let root_ids: Vec<&str> = root_scoped.iter().map(|r| r.entity_id.as_str()).collect(); + + assert_eq!( + root_ids, unscoped_ids, + "root scope should match unscoped search" + ); +} + +#[test] +fn test_multi_scope_with_empty_segment_does_not_widen() { + let graph = make_graph(); + let scoped = search( + &graph, + "authentication", + SearchMode::Features, + Some("Security/auth/token,"), + 10, + ); + let expected = search( + &graph, + "authentication", + SearchMode::Features, + Some("Security/auth/token"), + 10, + ); + + let scoped_ids: Vec<&str> = scoped.iter().map(|r| r.entity_id.as_str()).collect(); + let expected_ids: Vec<&str> = expected.iter().map(|r| r.entity_id.as_str()).collect(); + + assert_eq!( + scoped_ids, expected_ids, + "empty scope segments should be ignored, not widen to the full graph" + ); +} + #[test] fn test_multi_scope_dedup_overlapping() { let graph = make_graph();