From 53bfb24c9b28a93a45113fad885796c10792e05b Mon Sep 17 00:00:00 2001 From: VooDisss <41582720+VooDisss@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:34:22 +0300 Subject: [PATCH] fix: handle root scope in shared search Treat root scope aliases in the shared scoped-search helper so callers like plan_change can search from the repository root with scope='.' or an explicit empty scope. This fixes the failure mode where the shared hierarchy collector treated '.' as a literal hierarchy path and returned no entities, even though the same query succeeded when unscoped or when given a concrete semantic path. Keep the change in rpg-nav search instead of adding MCP- or planner-specific normalization so all shared search consumers inherit the same behavior automatically. The scope parser now maps whole-input root aliases to the full graph, preserves '.' as a root alias inside comma-separated scope lists, and ignores empty trailing scope fragments so malformed lists do not widen unexpectedly. Add search-layer regressions for root-scope parity and empty-fragment handling, plus a planner-level regression covering the reported user-visible failure mode. --- crates/rpg-nav/src/planner.rs | 33 +++++++++++++++++++++++ crates/rpg-nav/src/search.rs | 10 +++++++ crates/rpg-nav/tests/search.rs | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) 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();