Skip to content

Commit d28f925

Browse files
authored
fix: handle root scope in shared search (#70)
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.
1 parent 9158cd4 commit d28f925

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

crates/rpg-nav/src/planner.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,39 @@ mod tests {
410410
);
411411
}
412412

413+
#[test]
414+
fn test_plan_root_scope_matches_unscoped_search() {
415+
let graph = build_test_graph();
416+
let unscoped = plan_change(
417+
&graph,
418+
&PlanChangeRequest {
419+
goal: "server",
420+
scope: None,
421+
max_entities: 10,
422+
},
423+
None,
424+
);
425+
let root_scoped = plan_change(
426+
&graph,
427+
&PlanChangeRequest {
428+
goal: "server",
429+
scope: Some("."),
430+
max_entities: 10,
431+
},
432+
None,
433+
);
434+
435+
assert_eq!(
436+
root_scoped.relevant_entities.len(),
437+
unscoped.relevant_entities.len(),
438+
"root scope should search the full graph"
439+
);
440+
assert_eq!(
441+
root_scoped.modification_order, unscoped.modification_order,
442+
"root scope should preserve planning results"
443+
);
444+
}
445+
413446
#[test]
414447
fn test_plan_dependency_ordering() {
415448
let graph = build_test_graph();

crates/rpg-nav/src/search.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,11 +562,21 @@ fn maybe_hybrid_rerank(
562562
/// Collect entities from one or more hierarchy scopes.
563563
/// Supports comma-separated scopes per paper's `search_scopes` (list of paths).
564564
fn collect_scoped_entities(graph: &RPGraph, scope: &str) -> Vec<String> {
565+
if matches!(scope.trim(), "" | ".") {
566+
return graph.entities.keys().cloned().collect();
567+
}
568+
565569
let scopes: Vec<&str> = scope.split(',').map(|s| s.trim()).collect();
566570
let mut all_ids: Vec<String> = Vec::new();
567571
let mut seen: HashSet<String> = HashSet::new();
568572

569573
for single_scope in scopes {
574+
if single_scope == "." {
575+
return graph.entities.keys().cloned().collect();
576+
}
577+
if single_scope.is_empty() {
578+
continue;
579+
}
570580
for id in collect_single_scope(graph, single_scope) {
571581
if seen.insert(id.clone()) {
572582
all_ids.push(id);

crates/rpg-nav/tests/search.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,54 @@ fn test_multi_scope_with_invalid_segment() {
360360
assert_eq!(results[0].entity_name, "validate_token");
361361
}
362362

363+
#[test]
364+
fn test_root_scope_matches_unscoped_search() {
365+
let graph = make_graph();
366+
let unscoped = search(&graph, "authentication", SearchMode::Features, None, 10);
367+
let root_scoped = search(
368+
&graph,
369+
"authentication",
370+
SearchMode::Features,
371+
Some("."),
372+
10,
373+
);
374+
375+
let unscoped_ids: Vec<&str> = unscoped.iter().map(|r| r.entity_id.as_str()).collect();
376+
let root_ids: Vec<&str> = root_scoped.iter().map(|r| r.entity_id.as_str()).collect();
377+
378+
assert_eq!(
379+
root_ids, unscoped_ids,
380+
"root scope should match unscoped search"
381+
);
382+
}
383+
384+
#[test]
385+
fn test_multi_scope_with_empty_segment_does_not_widen() {
386+
let graph = make_graph();
387+
let scoped = search(
388+
&graph,
389+
"authentication",
390+
SearchMode::Features,
391+
Some("Security/auth/token,"),
392+
10,
393+
);
394+
let expected = search(
395+
&graph,
396+
"authentication",
397+
SearchMode::Features,
398+
Some("Security/auth/token"),
399+
10,
400+
);
401+
402+
let scoped_ids: Vec<&str> = scoped.iter().map(|r| r.entity_id.as_str()).collect();
403+
let expected_ids: Vec<&str> = expected.iter().map(|r| r.entity_id.as_str()).collect();
404+
405+
assert_eq!(
406+
scoped_ids, expected_ids,
407+
"empty scope segments should be ignored, not widen to the full graph"
408+
);
409+
}
410+
363411
#[test]
364412
fn test_multi_scope_dedup_overlapping() {
365413
let graph = make_graph();

0 commit comments

Comments
 (0)