use crate::server::helpers::{
format_degraded_notice, millis_to_u64, parse_semantic_path, pathfinder_to_error_data,
require_symbol_target, serialize_metadata,
};
use crate::server::types::AnalyzeImpactParams;
use crate::server::PathfinderServer;
use pathfinder_common::types::DegradedReason;
use pathfinder_lsp::LspError;
use rmcp::model::{CallToolResult, ErrorData};
const BFS_TIMEOUT_SECS: u64 = 30;
#[derive(Debug)]
enum CallDirection {
Incoming,
Outgoing,
}
impl PathfinderServer {
async fn grep_reference_fallback(
&self,
symbol_name: &str,
definition_path: &std::path::Path,
files_referenced: &mut std::collections::HashSet<String>,
) -> Option<Vec<crate::server::types::ImpactReference>> {
let search_params = crate::server::types::SearchCodebaseParams {
query: symbol_name.to_string(),
is_regex: false,
path_glob: "**/*".to_string(),
filter_mode: pathfinder_common::types::FilterMode::CodeOnly,
max_results: 20,
context_lines: 0,
known_files: vec![],
group_by_file: false,
exclude_glob: String::new(),
offset: 0,
};
let result = match self.search_codebase_impl(search_params).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
tool = "grep_reference_fallback",
symbol = %symbol_name,
error = %e,
"search_codebase_impl failed during grep fallback"
);
return None;
}
};
if result.0.matches.is_empty() {
return None;
}
let refs: Vec<crate::server::types::ImpactReference> = result
.0
.matches
.into_iter()
.filter(|m| {
let m_path = std::path::Path::new(&m.file);
super::is_source_file(&m.file) && m_path != definition_path
})
.take(10)
.map(|m| {
files_referenced.insert(m.file.clone());
let semantic_path = m
.enclosing_semantic_path
.clone()
.unwrap_or_else(|| format!("{}::{symbol_name}", m.file));
crate::server::types::ImpactReference {
semantic_path,
file: m.file,
line: usize::try_from(m.line).unwrap_or(usize::MAX),
snippet: m.content,
direction: "incoming_heuristic".to_string(),
depth: 0,
}
})
.collect();
if refs.is_empty() {
None
} else {
Some(refs)
}
}
#[allow(clippy::too_many_lines)]
async fn bfs_call_hierarchy(
&self,
initial_item: &pathfinder_lsp::types::CallHierarchyItem,
direction: CallDirection,
max_depth: u32,
files_referenced: &mut std::collections::HashSet<String>,
project_only: bool,
remaining_references: &mut u32,
) -> (Vec<crate::server::types::ImpactReference>, u32) {
let timeout = tokio::time::Duration::from_secs(BFS_TIMEOUT_SECS);
let deadline = tokio::time::Instant::now() + timeout;
let mut queue = std::collections::VecDeque::new();
queue.push_back((initial_item.clone(), 0));
let mut seen = std::collections::HashSet::new();
seen.insert((
initial_item.file.clone(),
initial_item.line,
initial_item.name.clone(),
));
files_referenced.insert(initial_item.file.clone());
let mut references = Vec::new();
let mut max_depth_reached = 0;
while let Some((item, current_depth)) = queue.pop_front() {
max_depth_reached = std::cmp::max(max_depth_reached, current_depth);
if current_depth >= max_depth {
continue;
}
if *remaining_references == 0 {
break;
}
if tokio::time::Instant::now() >= deadline {
tracing::warn!(
direction = ?direction,
timeout_secs = BFS_TIMEOUT_SECS,
"BFS traversal exceeded wall-clock timeout, returning partial results"
);
break;
}
let hierarchy_result = match direction {
CallDirection::Incoming => {
self.lawyer
.call_hierarchy_incoming(self.workspace_root.path(), &item)
.await
}
CallDirection::Outgoing => {
self.lawyer
.call_hierarchy_outgoing(self.workspace_root.path(), &item)
.await
}
};
match hierarchy_result {
Ok(calls) => {
for call in calls {
if *remaining_references == 0 {
break;
}
let referenced_item = call.item;
if project_only
&& (!super::is_source_file(&referenced_item.file)
|| !super::is_workspace_file(&referenced_item.file))
{
continue;
}
files_referenced.insert(referenced_item.file.clone());
let key = (
referenced_item.file.clone(),
referenced_item.line,
referenced_item.name.clone(),
);
if seen.insert(key) {
queue.push_back((referenced_item.clone(), current_depth + 1));
references.push(crate::server::types::ImpactReference {
semantic_path: format!(
"{}::{}",
referenced_item.file, referenced_item.name
),
file: referenced_item.file.clone(),
line: referenced_item.line as usize,
snippet: referenced_item
.detail
.unwrap_or_else(|| referenced_item.name.clone()),
direction: match direction {
CallDirection::Incoming => "incoming".to_owned(),
CallDirection::Outgoing => "outgoing".to_owned(),
},
depth: current_depth as usize,
});
*remaining_references -= 1;
}
}
}
Err(e) => {
let direction_name = match direction {
CallDirection::Incoming => "call_hierarchy_incoming",
CallDirection::Outgoing => "call_hierarchy_outgoing",
};
tracing::warn!(
tool = "analyze_impact",
error = %e,
file = %item.file,
line = item.line,
depth = current_depth,
"{direction_name} failed during BFS (partial impact graph)"
);
}
}
}
(references, max_depth_reached)
}
#[expect(
clippy::too_many_lines,
reason = "Sequential pipeline (parse→sandbox→tree-sitter→LSP→BFS→version hash)."
)]
pub(crate) async fn analyze_impact_impl(
&self,
params: AnalyzeImpactParams,
) -> Result<CallToolResult, ErrorData> {
let start = std::time::Instant::now();
let max_depth = params.max_depth.clamp(1, 5);
let project_only = params.project_only.unwrap_or(true);
let max_references = params.max_references.max(1);
let half = max_references / 2;
let mut remaining_incoming = half + max_references % 2;
let mut remaining_outgoing = half;
tracing::info!(
tool = "analyze_impact",
semantic_path = %params.semantic_path,
max_depth = max_depth,
"analyze_impact: start"
);
let semantic_path = parse_semantic_path(¶ms.semantic_path)?;
require_symbol_target(&semantic_path, ¶ms.semantic_path)?;
if let Err(e) = self.sandbox.check(&semantic_path.file_path) {
let duration_ms = start.elapsed().as_millis();
tracing::warn!(
tool = "analyze_impact",
error_code = e.error_code(),
duration_ms,
"sandbox check failed"
);
return Err(pathfinder_to_error_data(&e));
}
let abs_file = self.workspace_root.path().join(&semantic_path.file_path);
if !abs_file.exists() {
let err = pathfinder_common::error::PathfinderError::FileNotFound {
path: abs_file.clone(),
};
tracing::warn!(
tool = "analyze_impact",
path = %abs_file.display(),
"file not found"
);
return Err(pathfinder_to_error_data(&err));
}
let ts_start = std::time::Instant::now();
let scope = match self
.read_symbol_scope_enriched(&semantic_path, ¶ms.semantic_path)
.await
{
Ok(s) => s,
Err(e) => {
let duration_ms = start.elapsed().as_millis();
tracing::warn!(
tool = "analyze_impact",
error = %e,
duration_ms,
"tree-sitter read failed"
);
return Err(e);
}
};
let tree_sitter_ms = ts_start.elapsed().as_millis();
let file_path = self.workspace_root.path().join(&semantic_path.file_path);
let file_content = match tokio::fs::read_to_string(&file_path).await {
Ok(content) => content,
Err(e) => {
tracing::warn!(
tool = "analyze_impact",
path = %file_path.display(),
error = %e,
"file read failed — LSP will receive empty content"
);
String::new()
}
};
let _doc_guard = match self
.lawyer
.open_document(
self.workspace_root.path(),
&semantic_path.file_path,
&file_content,
)
.await
{
Ok(guard) => Some(guard),
Err(e) => {
tracing::warn!(
tool = "analyze_impact",
semantic_path = %semantic_path,
error = %e,
"open_document failed — LSP queries may return degraded results"
);
None
}
};
let lsp_start = std::time::Instant::now();
let mut incoming: Option<Vec<crate::server::types::ImpactReference>> = None;
let mut outgoing: Option<Vec<crate::server::types::ImpactReference>> = None;
let mut degraded = true;
let mut degraded_reason = Some(DegradedReason::NoLsp);
let mut engines = vec!["tree-sitter"];
let mut files_referenced = std::collections::HashSet::new();
let mut max_depth_reached = 0;
let lsp_result = self
.lawyer
.call_hierarchy_prepare(
self.workspace_root.path(),
&semantic_path.file_path,
u32::try_from(scope.start_line + 1).unwrap_or(1),
u32::try_from(scope.name_column + 1).unwrap_or(1),
)
.await;
match lsp_result {
Ok(items) if !items.is_empty() => {
engines.push("lsp");
degraded = false;
degraded_reason = None;
let initial_item = &items[0];
files_referenced.insert(initial_item.file.clone());
let (incoming_refs, depth_in) = self
.bfs_call_hierarchy(
initial_item,
CallDirection::Incoming,
max_depth,
&mut files_referenced,
project_only,
&mut remaining_incoming,
)
.await;
incoming = Some(incoming_refs);
max_depth_reached = std::cmp::max(max_depth_reached, depth_in);
let (outgoing_refs, depth_out) = self
.bfs_call_hierarchy(
initial_item,
CallDirection::Outgoing,
max_depth,
&mut files_referenced,
project_only,
&mut remaining_outgoing,
)
.await;
outgoing = Some(outgoing_refs);
max_depth_reached = std::cmp::max(max_depth_reached, depth_out);
}
Ok(_) => {
let probe = self
.lawyer
.goto_definition(
self.workspace_root.path(),
&semantic_path.file_path,
u32::try_from(scope.start_line + 1).unwrap_or(1),
u32::try_from(scope.name_column + 1).unwrap_or(1),
)
.await;
if matches!(probe, Ok(Some(_))) {
engines.push("lsp");
degraded = false;
degraded_reason = None;
incoming = Some(Vec::new());
outgoing = Some(Vec::new());
} else {
tracing::info!(
tool = "analyze_impact",
symbol = %semantic_path,
"analyze_impact: call_hierarchy_prepare returned [] but goto_definition \
probe returned no result — LSP likely warming up, attempting grep-based reference fallback"
);
engines.push("lsp");
degraded = true;
degraded_reason = Some(DegradedReason::LspWarmupEmptyUnverified);
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
if let Some(refs) = self
.grep_reference_fallback(
&symbol_name,
&semantic_path.file_path,
&mut files_referenced,
)
.await
{
incoming = Some(refs);
degraded_reason = Some(DegradedReason::LspWarmupGrepFallback);
tracing::info!(
tool = "analyze_impact",
references_found = incoming.as_ref().map_or(0, Vec::len),
"analyze_impact: grep-based fallback references found during LSP warmup"
);
}
}
}
Err(LspError::NoLspAvailable | LspError::UnsupportedCapability { .. }) => {
tracing::info!(
tool = "analyze_impact",
symbol = %semantic_path,
"analyze_impact: no LSP — attempting grep-based reference fallback"
);
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
if let Some(refs) = self
.grep_reference_fallback(
&symbol_name,
&semantic_path.file_path,
&mut files_referenced,
)
.await
{
incoming = Some(refs);
degraded_reason = Some(DegradedReason::NoLspGrepFallback);
tracing::info!(
tool = "analyze_impact",
references_found = incoming.as_ref().map_or(0, Vec::len),
"analyze_impact: grep-based fallback references found"
);
}
}
Err(LspError::Timeout { .. }) => {
tracing::info!(
tool = "analyze_impact",
symbol = %semantic_path,
"analyze_impact: LSP timed out — attempting grep-based reference fallback"
);
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
if let Some(refs) = self
.grep_reference_fallback(
&symbol_name,
&semantic_path.file_path,
&mut files_referenced,
)
.await
{
incoming = Some(refs);
degraded_reason = Some(DegradedReason::LspTimeoutGrepFallback);
tracing::info!(
tool = "analyze_impact",
references_found = incoming.as_ref().map_or(0, Vec::len),
"analyze_impact: grep-based fallback references found after timeout"
);
}
}
Err(e) => {
degraded = true;
degraded_reason = Some(DegradedReason::NoLsp);
tracing::warn!(
tool = "analyze_impact",
error = %e,
"call_hierarchy_prepare failed"
);
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
if let Some(refs) = self
.grep_reference_fallback(
&symbol_name,
&semantic_path.file_path,
&mut files_referenced,
)
.await
{
incoming = Some(refs);
degraded_reason = Some(DegradedReason::LspErrorGrepFallback);
tracing::info!(
tool = "analyze_impact",
references_found = incoming.as_ref().map_or(0, Vec::len),
"analyze_impact: grep-based fallback references found after LSP error"
);
}
}
}
let lsp_ms = lsp_start.elapsed().as_millis();
let duration_ms = start.elapsed().as_millis();
let inc_count = incoming.as_ref().map_or(0, Vec::len);
let out_count = outgoing.as_ref().map_or(0, Vec::len);
let degraded_reason_cloned = degraded_reason;
let degraded_reason_str = degraded_reason.as_ref().map(ToString::to_string);
let lsp_readiness = if degraded {
match degraded_reason_cloned {
Some(
DegradedReason::LspWarmupEmptyUnverified
| DegradedReason::LspWarmupGrepFallback,
) => Some("warming_up".to_owned()),
_ => Some("unavailable".to_owned()),
}
} else {
Some("ready".to_owned())
};
let warm_start_in_progress = match lsp_readiness.as_deref() {
Some("warming_up") => Some(true),
Some("ready") => Some(false),
_ => None,
};
tracing::info!(
tool = "analyze_impact",
semantic_path = %params.semantic_path,
tree_sitter_ms,
lsp_ms,
duration_ms,
degraded,
degraded_reason = ?degraded_reason_str,
engines_used = ?engines,
"analyze_impact: complete"
);
let total_returned = inc_count + out_count;
let max_refs_usize = usize::try_from(max_references).unwrap_or(usize::MAX);
let references_truncated = max_references > 0 && total_returned >= max_refs_usize;
let resolution_strategy = if engines.contains(&"lsp") {
Some("lsp_call_hierarchy".to_owned())
} else if degraded {
match °raded_reason {
Some(
DegradedReason::LspWarmupGrepFallback
| DegradedReason::LspTimeoutGrepFallback
| DegradedReason::LspErrorGrepFallback
| DegradedReason::NoLspGrepFallback,
) => Some("grep_file_scoped".to_owned()),
_ => Some("treesitter_fallback".to_owned()),
}
} else {
Some("treesitter_direct".to_owned())
};
let (test_callers, test_coverage_status) = if params.include_test_coverage {
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
if symbol_name.is_empty() {
(None, Some("not_found".to_owned()))
} else {
let search_params = pathfinder_search::SearchParams {
workspace_root: self.workspace_root.path().to_path_buf(),
query: symbol_name.clone(),
is_regex: false,
path_glob: "**/*{test,spec,Test,Spec,__tests__}*".to_owned(),
exclude_glob: String::new(),
max_results: 100,
offset: 0,
context_lines: 2,
};
match self.scout.search(&search_params).await {
Ok(results) => {
let test_refs: Vec<crate::server::types::ImpactReference> = results
.matches
.into_iter()
.filter(|m| super::is_test_file(&m.file))
.take(20) .map(|m| {
let fallback_path = format!("{}:{}", m.file, m.line);
crate::server::types::ImpactReference {
semantic_path: m
.enclosing_semantic_path
.unwrap_or(fallback_path),
file: m.file.clone(),
line: usize::try_from(m.line).unwrap_or(0),
snippet: m.content,
direction: "test_coverage".to_owned(),
depth: 0,
}
})
.collect();
if test_refs.is_empty() {
(None, Some("not_found".to_owned()))
} else {
(Some(test_refs), Some("found".to_owned()))
}
}
Err(e) => {
tracing::warn!(
tool = "analyze_impact",
error = %e,
"test coverage search failed"
);
(None, Some("unknown_degraded".to_owned()))
}
}
}
} else {
(None, None)
};
let metadata = crate::server::types::AnalyzeImpactMetadata {
incoming,
outgoing,
depth_reached: max_depth_reached,
files_referenced: files_referenced.len(),
degraded,
degraded_reason,
actionable_guidance: degraded_reason.as_ref().map(DegradedReason::guidance),
lsp_readiness,
warm_start_in_progress,
references_truncated,
resolution_strategy,
test_callers,
test_coverage_status,
duration_ms: Some(millis_to_u64(duration_ms)),
};
let mut text_parts = Vec::new();
if degraded {
let notice = degraded_reason_cloned
.as_ref()
.map_or_else(|| "DEGRADED (unknown)".to_owned(), format_degraded_notice);
let symbol_name = super::last_symbol_name(&semantic_path).unwrap_or_default();
text_parts.push(notice);
text_parts.push(String::new());
text_parts.push(" Common causes:".to_owned());
text_parts.push(" - Interface types without concrete implementations in source (JPA repositories)".to_owned());
text_parts.push(
" - Annotation-driven dependency injection (Spring proxies at runtime)"
.to_owned(),
);
text_parts.push(" - LSP still warming up (wait 30s, try again)".to_owned());
text_parts.push(String::new());
if symbol_name.is_empty() {
text_parts
.push(" Workaround: Use search_codebase to find usages manually.".to_owned());
} else {
text_parts.push(format!(
" Workaround: Use search_codebase(query=\"{symbol_name}\") to find usages manually."
));
}
text_parts.push(" Reference counts below are heuristic only:".to_owned());
text_parts.push(String::new());
} else if inc_count == 0 && out_count == 0 {
text_parts.push("LSP confirmed: zero callers/callees for this symbol.".to_string());
} else if inc_count == 0 {
text_parts
.push("LSP confirmed: zero incoming callers (callees found below).".to_string());
} else if out_count == 0 {
text_parts
.push("LSP confirmed: zero outgoing callees (callers found below).".to_string());
}
text_parts.push(format!("Incoming references: {inc_count}"));
if let Some(refs) = &metadata.incoming {
for r in refs {
text_parts.push(format!(
" [depth={}] {} ({}:L{})",
r.depth, r.semantic_path, r.file, r.line
));
if !r.snippet.is_empty() {
text_parts.push(format!(" > {}", r.snippet.trim()));
}
}
}
text_parts.push(format!("Outgoing references: {out_count}"));
if let Some(refs) = &metadata.outgoing {
for r in refs {
text_parts.push(format!(
" [depth={}] {} ({}:L{})",
r.depth, r.semantic_path, r.file, r.line
));
if !r.snippet.is_empty() {
text_parts.push(format!(" > {}", r.snippet.trim()));
}
}
}
if let Some(test_refs) = &metadata.test_callers {
if !test_refs.is_empty() {
text_parts.push(String::new());
text_parts.push(format!(
"TEST COVERAGE: {} test functions cover this symbol",
test_refs.len()
));
for r in test_refs {
text_parts.push(format!(
" - {}::{} ({}:L{})",
r.file, r.semantic_path, r.file, r.line
));
}
}
} else if let Some(status) = &metadata.test_coverage_status {
if status == "not_found" {
text_parts.push(String::new());
text_parts
.push("TEST COVERAGE: no test functions found for this symbol".to_owned());
} else if status == "unknown_degraded" {
text_parts.push(String::new());
text_parts.push("TEST COVERAGE: unknown (search degraded)".to_owned());
}
}
text_parts.push(format!("[completed in {duration_ms}ms]"));
let text = text_parts.join("\n");
let mut res = CallToolResult::success(vec![rmcp::model::Content::text(text)]);
res.structured_content = serialize_metadata(&metadata);
Ok(res)
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::super::test_helpers::{make_scope, make_server_with_lawyer, make_temp_workspace};
use super::*;
use crate::server::types::AnalyzeImpactParams;
use pathfinder_common::config::PathfinderConfig;
use pathfinder_common::sandbox::Sandbox;
use pathfinder_common::types::{DegradedReason, WorkspaceRoot};
use pathfinder_lsp::types::{CallHierarchyCall, CallHierarchyItem};
use pathfinder_lsp::{DefinitionLocation, MockLawyer};
use pathfinder_search::MockScout;
use pathfinder_treesitter::mock::MockSurgeon;
use std::sync::Arc;
#[tokio::test]
async fn test_analyze_impact_returns_empty_degraded() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
let server = PathfinderServer::with_all_engines(
ws,
config,
sandbox,
Arc::new(MockScout::default()),
surgeon,
lawyer,
);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(
val.incoming.is_none(),
"incoming must be null (not empty) when degraded"
);
assert!(
val.outgoing.is_none(),
"outgoing must be null (not empty) when degraded"
);
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLsp));
}
#[tokio::test]
async fn test_analyze_impact_lsp_populates_incoming_and_outgoing() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "handle_request".into(),
kind: "function".into(),
detail: Some("fn handle_request()".into()),
file: "src/server.rs".into(),
line: 20,
column: 4,
data: None,
},
call_sites: vec![25],
}]));
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "validate_token".into(),
kind: "function".into(),
detail: Some("fn validate_token() -> bool".into()),
file: "src/token.rs".into(),
line: 15,
column: 4,
data: None,
},
call_sites: vec![9],
}]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
assert_eq!(val.degraded_reason, None);
assert_eq!(val.depth_reached, 1); assert_eq!(val.files_referenced, 3); let incoming = val
.incoming
.as_ref()
.expect("incoming must be Some when not degraded");
let outgoing = val
.outgoing
.as_ref()
.expect("outgoing must be Some when not degraded");
assert_eq!(incoming.len(), 1);
assert_eq!(incoming[0].file, "src/server.rs");
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].file, "src/token.rs");
}
#[tokio::test]
async fn test_analyze_impact_empty_hierarchy_confirmed_zero() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
lawyer.push_prepare_call_hierarchy_result(Ok(vec![]));
lawyer.set_goto_definition_result(Ok(Some(DefinitionLocation {
file: "src/auth.rs".into(),
line: 10,
column: 4,
preview: "fn login() {}".into(),
})));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(
!val.degraded,
"must not be degraded when probe confirms LSP is warm"
);
assert_eq!(val.degraded_reason, None);
let incoming = val
.incoming
.as_ref()
.expect("must be Some when confirmed-zero");
let outgoing = val
.outgoing
.as_ref()
.expect("must be Some when confirmed-zero");
assert!(incoming.is_empty(), "confirmed zero callers");
assert!(outgoing.is_empty(), "confirmed zero callees");
}
#[tokio::test]
async fn test_analyze_impact_empty_hierarchy_warmup_degrades() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
lawyer.push_prepare_call_hierarchy_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(
val.degraded,
"must be degraded when goto_definition probe also returns None"
);
assert_eq!(
val.degraded_reason,
Some(DegradedReason::LspWarmupEmptyUnverified),
"degraded_reason must indicate warmup ambiguity"
);
assert!(
val.incoming.is_none(),
"incoming must be None (unknown) during warmup, not Some([]) (confirmed-zero)"
);
assert!(
val.outgoing.is_none(),
"outgoing must be None (unknown) during warmup, not Some([]) (confirmed-zero)"
);
}
#[tokio::test]
async fn test_analyze_impact_lsp_error_degrades() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
lawyer
.push_prepare_call_hierarchy_result(Err(LspError::Protocol("LSP crashed".to_string())));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLsp));
}
#[tokio::test]
async fn test_analyze_impact_bfs_respects_max_depth() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
let caller_item = CallHierarchyItem {
name: "caller".into(),
kind: "function".into(),
detail: None,
file: "src/caller.rs".into(),
line: 5,
column: 4,
data: None,
};
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: caller_item.clone(),
call_sites: vec![9],
}]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "top_level".into(),
kind: "function".into(),
detail: None,
file: "src/main.rs".into(),
line: 1,
column: 0,
data: None,
},
call_sites: vec![5],
}]));
lawyer.push_outgoing_call_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1, ..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
let _incoming = val.incoming.as_ref().expect("must be Some");
assert_eq!(val.depth_reached, 1);
}
#[tokio::test]
async fn test_analyze_impact_rejects_sandbox_denied_path() {
let surgeon = Arc::new(MockSurgeon::new());
let lawyer = Arc::new(MockLawyer::default());
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: ".git/objects/abc::def".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let Err(err) = result else {
panic!("expected error but got Ok");
};
let code = err
.data
.as_ref()
.and_then(|d| d.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(code, "ACCESS_DENIED");
}
#[tokio::test]
async fn test_analyze_impact_tree_sitter_error() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon.read_symbol_scope_results.lock().unwrap().push(Err(
pathfinder_treesitter::SurgeonError::ParseError {
path: std::path::PathBuf::from("src/auth.rs"),
reason: "parse failed".to_string(),
},
));
let lawyer = Arc::new(MockLawyer::default());
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
assert!(result.is_err(), "tree-sitter error should propagate");
}
#[tokio::test]
async fn test_analyze_impact_bfs_lsp_error_graceful_partial_graph() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "caller".into(),
kind: "function".into(),
detail: None,
file: "src/server.rs".into(),
line: 20,
column: 4,
data: None,
},
call_sites: vec![9],
}]));
lawyer.push_outgoing_call_result(Err(LspError::Protocol(
"LSP crashed during outgoing".to_string(),
)));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed despite partial failure");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
let incoming = val.incoming.as_ref().expect("incoming must be Some");
assert_eq!(incoming.len(), 1, "incoming caller should be present");
let outgoing = val.outgoing.as_ref().expect("outgoing must be Some");
assert!(outgoing.is_empty(), "outgoing should be empty due to error");
}
#[tokio::test]
async fn test_analyze_impact_grep_fallback_with_mock_scout() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/auth.rs"),
"fn login() -> bool { true }",
)
.unwrap();
let scout = Arc::new(MockScout::default());
std::fs::write(
ws_dir.path().join("src/caller.rs"),
"fn handle_request() { login(); }",
)
.unwrap();
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![pathfinder_search::SearchMatch {
file: "src/caller.rs".to_string(),
line: 1,
column: 1,
content: "fn handle_request() { login(); }".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:abc".to_string(),
known: Some(false),
}],
total_matches: 1,
truncated: false,
files_searched: 0,
files_in_scope: 0,
}));
let server = PathfinderServer::with_all_engines(
ws,
config,
sandbox,
scout,
surgeon,
Arc::new(pathfinder_lsp::NoOpLawyer),
);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLspGrepFallback));
let incoming = val.incoming.as_ref().expect("must be Some from grep");
assert_eq!(incoming.len(), 1);
assert_eq!(incoming[0].file, "src/caller.rs");
assert_eq!(incoming[0].direction, "incoming_heuristic");
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn test_analyze_impact_grep_fallback_filters_non_source_files() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.extend([Ok(None), Ok(None), Ok(None), Ok(None)]);
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/auth.rs"),
"fn login() -> bool { true }",
)
.unwrap();
let scout = Arc::new(MockScout::default());
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![
pathfinder_search::SearchMatch {
file: "src/caller.rs".to_string(),
line: 1,
column: 1,
content: "fn call() { login(); }".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:a".to_string(),
known: Some(false),
},
pathfinder_search::SearchMatch {
file: "docs/README.md".to_string(),
line: 10,
column: 1,
content: "call login() to authenticate".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:b".to_string(),
known: Some(false),
},
pathfinder_search::SearchMatch {
file: "config.json".to_string(),
line: 5,
column: 1,
content: "\"login\": \"/api/auth\"".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:c".to_string(),
known: Some(false),
},
pathfinder_search::SearchMatch {
file: "web/src/auth.ts".to_string(),
line: 20,
column: 1,
content: "import { login } from './api';".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:d".to_string(),
known: Some(false),
},
],
total_matches: 4,
truncated: false,
files_searched: 0,
files_in_scope: 0,
}));
let server = PathfinderServer::with_all_engines(
ws,
config,
sandbox,
scout,
surgeon,
Arc::new(pathfinder_lsp::NoOpLawyer),
);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLspGrepFallback));
let incoming = val.incoming.as_ref().expect("must be Some from grep");
assert_eq!(
incoming.len(),
2,
"non-source files should be filtered, got: {:?}",
incoming.iter().map(|r| &r.file).collect::<Vec<_>>()
);
let files: std::collections::HashSet<_> =
incoming.iter().map(|r| r.file.as_str()).collect();
assert!(files.contains("src/caller.rs"), "should keep .rs file");
assert!(files.contains("web/src/auth.ts"), "should keep .ts file");
assert!(!files.contains("docs/README.md"), "should filter .md file");
assert!(!files.contains("config.json"), "should filter .json file");
}
#[tokio::test]
async fn test_analyze_impact_closes_document_on_success() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer.clone());
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
..Default::default()
};
let _ = server.analyze_impact_impl(params).await;
tokio::task::yield_now().await;
assert_eq!(
lawyer.did_open_call_count(),
lawyer.did_close_call_count(),
"DS-1: did_open and did_close must be symmetric in analyze_impact"
);
}
#[tokio::test]
async fn test_analyze_impact_project_only_false_includes_external_refs() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "handle_request".into(),
kind: "function".into(),
detail: None,
file: "src/server.rs".into(),
line: 20,
column: 4,
data: None,
},
call_sites: vec![25],
}]));
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "write_all".into(),
kind: "function".into(),
detail: None,
file: "/home/user/.rustup/toolchains/stable/lib/std/io.rs".into(),
line: 100,
column: 4,
data: None,
},
call_sites: vec![10],
}]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
project_only: Some(false), ..Default::default()
};
let result = server
.analyze_impact_impl(params)
.await
.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(result.structured_content.unwrap()).unwrap();
let outgoing = val.outgoing.as_ref().expect("outgoing must be Some");
assert_eq!(
outgoing.len(),
1,
"project_only=false should include the stdlib absolute path ref"
);
assert!(
outgoing[0].file.starts_with('/'),
"outgoing ref should be the absolute stdlib path"
);
}
#[tokio::test]
async fn test_analyze_impact_project_only_true_filters_stdlib_refs() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
lawyer.push_incoming_call_result(Ok(vec![]));
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "write_all".into(),
kind: "function".into(),
detail: None,
file: "/home/user/.rustup/toolchains/stable/lib/std/io.rs".into(),
line: 100,
column: 4,
data: None,
},
call_sites: vec![10],
}]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
..Default::default()
};
let result = server
.analyze_impact_impl(params)
.await
.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(result.structured_content.unwrap()).unwrap();
let outgoing = val.outgoing.as_ref().expect("outgoing must be Some");
assert_eq!(
outgoing.len(),
0,
"project_only=true (default) must filter out stdlib absolute paths"
);
}
#[tokio::test]
async fn test_analyze_impact_max_references_truncates_results() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
let incoming_calls: Vec<CallHierarchyCall> = (1..=5)
.map(|i| CallHierarchyCall {
item: CallHierarchyItem {
name: format!("caller_{i}"),
kind: "function".into(),
detail: None,
file: format!("src/caller_{i}.rs"),
line: i * 10,
column: 4,
data: None,
},
call_sites: vec![i * 10],
})
.collect();
lawyer.push_incoming_call_result(Ok(incoming_calls));
let outgoing_calls: Vec<CallHierarchyCall> = (1..=3)
.map(|i| CallHierarchyCall {
item: CallHierarchyItem {
name: format!("callee_{i}"),
kind: "function".into(),
detail: None,
file: format!("src/callee_{i}.rs"),
line: i * 10,
column: 4,
data: None,
},
call_sites: vec![i * 10],
})
.collect();
lawyer.push_outgoing_call_result(Ok(outgoing_calls));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
max_references: 2, ..Default::default()
};
let result = server
.analyze_impact_impl(params)
.await
.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(result.structured_content.unwrap()).unwrap();
let incoming = val.incoming.as_ref().expect("incoming must be Some");
assert_eq!(
incoming.len(),
1,
"incoming refs must be capped at max_references/2=1"
);
assert!(
val.references_truncated,
"references_truncated must be true when total budget is exhausted"
);
}
#[test]
fn test_analyze_impact_default_max_references_is_50() {
use crate::server::types::default_max_references;
assert_eq!(
default_max_references(),
50,
"default_max_references must be 50 per the remediation plan spec"
);
}
#[tokio::test]
async fn test_analyze_impact_handles_empty_incoming_and_outgoing() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
lawyer.push_prepare_call_hierarchy_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 3,
max_references: 50,
project_only: Some(true),
include_test_coverage: false,
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.incoming.is_none() || val.incoming.as_ref().unwrap().is_empty());
assert!(val.outgoing.is_none() || val.outgoing.as_ref().unwrap().is_empty());
}
#[tokio::test]
async fn test_analyze_impact_respects_max_depth() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "main".into(),
kind: "function".into(),
detail: None,
file: "src/main.rs".into(),
line: 5,
column: 4,
data: None,
},
call_sites: vec![5],
}]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1, max_references: 50,
project_only: Some(true),
include_test_coverage: false,
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
let incoming = val
.incoming
.as_ref()
.expect("incoming must be Some when not degraded");
assert!(!incoming.is_empty(), "should have incoming calls");
assert!(
incoming.iter().all(|r| r.depth <= 1),
"all refs should be within max_depth"
);
}
#[tokio::test]
async fn test_analyze_impact_bfs_handles_cycle_in_call_graph() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item_a = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item_a.clone()]));
let item_b = CallHierarchyItem {
name: "validate_token".into(),
kind: "function".into(),
detail: None,
file: "src/token.rs".into(),
line: 20,
column: 4,
data: None,
};
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: item_b.clone(),
call_sites: vec![15],
}]));
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: item_a.clone(),
call_sites: vec![25],
}]));
lawyer.push_incoming_call_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 3,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
let outgoing = val.outgoing.as_ref().expect("must be Some");
assert!(
!outgoing
.iter()
.any(|r| r.file == "src/auth.rs" && r.semantic_path.contains("login")),
"cycle should be deduplicated"
);
}
#[tokio::test]
async fn test_analyze_impact_bfs_deduplicates_cross_referenced_symbols() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item.clone()]));
let caller_item = CallHierarchyItem {
name: "handler".into(),
kind: "function".into(),
detail: None,
file: "src/handler.rs".into(),
line: 10,
column: 4,
data: None,
};
lawyer.push_incoming_call_result(Ok(vec![
CallHierarchyCall {
item: caller_item.clone(),
call_sites: vec![20],
},
CallHierarchyCall {
item: caller_item.clone(),
call_sites: vec![35],
},
]));
lawyer.push_outgoing_call_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
let incoming = val.incoming.as_ref().expect("must be Some");
let handler_count = incoming
.iter()
.filter(|r| r.semantic_path.contains("handler") || r.file == "src/handler.rs")
.count();
assert_eq!(
handler_count, 1,
"cross-referenced symbol should be deduplicated"
);
}
#[tokio::test]
async fn test_analyze_impact_grep_fallback_provides_incoming_heuristic() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/auth.rs"),
"fn login() -> bool { true }",
)
.unwrap();
std::fs::write(
ws_dir.path().join("src/caller.rs"),
"fn handle_request() { login(); }",
)
.unwrap();
let scout = Arc::new(MockScout::default());
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![pathfinder_search::SearchMatch {
file: "src/caller.rs".to_string(),
line: 1,
column: 1,
content: "fn handle_request() { login(); }".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "sha256:abc".to_string(),
known: Some(false),
}],
total_matches: 1,
truncated: false,
files_searched: 0,
files_in_scope: 0,
}));
let server = PathfinderServer::with_all_engines(
ws,
config,
sandbox,
scout,
surgeon,
Arc::new(pathfinder_lsp::NoOpLawyer),
);
let params = crate::server::types::AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLspGrepFallback));
let incoming = val.incoming.as_ref().expect("must be Some from grep");
assert_eq!(incoming.len(), 1);
assert_eq!(incoming[0].file, "src/caller.rs");
assert_eq!(incoming[0].direction, "incoming_heuristic");
}
#[tokio::test]
async fn test_analyze_impact_grep_fallback_no_results_stays_none() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/auth.rs"),
"fn login() -> bool { validate_token() }",
)
.unwrap();
std::fs::write(
ws_dir.path().join("src/token.rs"),
"fn validate_token() -> bool { true }",
)
.unwrap();
let scout = Arc::new(MockScout::default());
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![],
total_matches: 0,
truncated: false,
files_searched: 0,
files_in_scope: 0,
}));
let server = PathfinderServer::with_all_engines(
ws,
config,
sandbox,
scout,
surgeon,
Arc::new(pathfinder_lsp::NoOpLawyer),
);
let params = crate::server::types::AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(val.degraded);
assert_eq!(val.degraded_reason, Some(DegradedReason::NoLsp));
assert!(
val.outgoing.is_none(),
"outgoing should be None — grep fallback cannot determine what this symbol calls"
);
assert!(
val.incoming.is_none(),
"incoming should be None when search returns no matches"
);
}
#[tokio::test]
async fn test_analyze_impact_bfs_continues_after_single_node_error() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
lawyer.push_incoming_call_result(Err(LspError::Protocol("transient error".to_string())));
lawyer.push_outgoing_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "validate_token".into(),
kind: "function".into(),
detail: Some("fn validate_token()".into()),
file: "src/token.rs".into(),
line: 15,
column: 4,
data: None,
},
call_sites: vec![9],
}]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
max_references: 50,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed despite BFS error");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(!val.degraded);
let incoming = val.incoming.as_ref().expect("incoming must be Some");
assert!(incoming.is_empty(), "incoming should be empty after error");
let outgoing = val.outgoing.as_ref().expect("outgoing must be Some");
assert_eq!(outgoing.len(), 1);
assert_eq!(outgoing[0].file, "src/token.rs");
}
#[tokio::test]
async fn test_analyze_impact_bfs_formats_response_correctly() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
lawyer.push_incoming_call_result(Ok(vec![CallHierarchyCall {
item: CallHierarchyItem {
name: "handle_request".into(),
kind: "function".into(),
detail: Some("fn handle_request()".into()),
file: "src/server.rs".into(),
line: 20,
column: 4,
data: None,
},
call_sites: vec![25],
}]));
lawyer.push_outgoing_call_result(Ok(vec![]));
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 1,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let text = match &call_res.content[0].raw {
rmcp::model::RawContent::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(text.contains("Incoming references: 1"), "text: {text}");
assert!(text.contains("Outgoing references: 0"), "text: {text}");
assert!(text.contains("[depth="), "text: {text}");
assert!(text.contains("src/server.rs:L20"), "text: {text}");
assert!(text.contains("[completed in"), "text: {text}");
}
#[tokio::test]
async fn test_analyze_impact_with_test_coverage() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
lawyer.push_incoming_call_result(Ok(vec![]));
lawyer.push_outgoing_call_result(Ok(vec![]));
let scout = Arc::new(MockScout::default());
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![pathfinder_search::SearchMatch {
file: "src/auth_test.rs".to_string(),
line: 10,
column: 4,
content: "fn test_login() { login(); }".to_string(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: Some("src/auth_test.rs::test_login".to_string()),
is_definition: Some(true),
version_hash: "sha256:abc".to_string(),
known: Some(false),
}],
total_matches: 1,
truncated: false,
files_searched: 1,
files_in_scope: 1,
}));
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
include_test_coverage: true,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(
val.test_callers.is_some(),
"test_callers should be populated"
);
let test_refs = val.test_callers.as_ref().unwrap();
assert_eq!(test_refs.len(), 1);
assert_eq!(test_refs[0].file, "src/auth_test.rs");
assert_eq!(test_refs[0].direction, "test_coverage");
assert_eq!(val.test_coverage_status, Some("found".to_owned()));
}
#[tokio::test]
async fn test_analyze_impact_test_coverage_not_found() {
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.read_symbol_scope_results
.lock()
.unwrap()
.push(Ok(make_scope()));
let lawyer = Arc::new(MockLawyer::default());
let item = CallHierarchyItem {
name: "login".into(),
kind: "function".into(),
detail: None,
file: "src/auth.rs".into(),
line: 9,
column: 4,
data: None,
};
lawyer.push_prepare_call_hierarchy_result(Ok(vec![item]));
lawyer.push_incoming_call_result(Ok(vec![]));
lawyer.push_outgoing_call_result(Ok(vec![]));
let scout = Arc::new(MockScout::default());
scout.set_result(Ok(pathfinder_search::SearchResult {
matches: vec![],
total_matches: 0,
truncated: false,
files_searched: 0,
files_in_scope: 0,
}));
let ws_dir = make_temp_workspace();
let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = AnalyzeImpactParams {
semantic_path: "src/auth.rs::login".to_owned(),
max_depth: 2,
include_test_coverage: true,
..Default::default()
};
let result = server.analyze_impact_impl(params).await;
let call_res = result.expect("should succeed");
let val: crate::server::types::AnalyzeImpactMetadata =
serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
assert!(
val.test_callers.is_none(),
"test_callers should be None when not found"
);
assert_eq!(val.test_coverage_status, Some("not_found".to_owned()));
}
}