diff --git a/CHANGELOG.md b/CHANGELOG.md index c591086..2ae6d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ All notable changes to CodeGraph are documented here. Versions follow [Semantic Versioning](https://semver.org/). Each release is tagged as `vscode/vX.Y.Z` in the git history. +## [Unreleased] + +### MCP Server Fixes + +Symbol resolution and call-graph accuracy fixes, plus lower token cost on +existing tools. + +- **Memory-gate fix** — `available_memory == 0` (a real `sysinfo` reading + under memory pressure) was treated as "no memory available" and forced + graph-only mode. Now treated as "unknown" so the embedding model still + loads. +- **`nodeId` as a number was silently rejected** — tools required + `nodeId` as a string, but `codegraph_symbol_search` returns it as a + number; passing it straight back caused false "node not found" errors. + Fixed across `get_callers`, `get_callees`, `traverse_graph`, + `get_symbol_info`, `get_detailed_symbol`. +- **Relative vs `file://` path mismatch** — path comparisons used strict + `==` between workspace-relative and `file://`-resolved paths, so they + never matched; a URI-parse failure could even resolve to an unrelated + symbol silently. Fixed with boundary-aware path matching across all + `uri`-resolving tools. +- **`codegraph_reindex_workspace` false success** — a soft reindex could + report `success` on an empty graph; now reports `degraded` and + auto-retries once with `force=true`. +- **Structured "symbol not found" reasons** — replaced a single generic + error with `InvalidUri` / `WorkspaceNotIndexed` / `FileNotIndexed`, + each with an actionable hint and suggested next query. +- **`match_confidence` field** — `"exact" | "fallback"` now always present + on `uri+line`-resolving tool responses (previously only present as an + optional `used_fallback` flag). +- **`codegraph_probe_symbol`** (new) — lightweight resolution check + (name/type/node_id/uri/line range/confidence only, no source or + callers/callees) for verifying a query hit the right symbol cheaply. +- **`get_detailed_symbol` deduplication** — callers/callees were computed + and serialized twice (once nested, once top-level); now computed once. + Added opt-in `compact: bool` to truncate long lists. +- **`get_edit_context` section toggles** — added + `includeSymbol`/`includeCallers`/`includeTests`/`includeMemories`/ + `includeRecentChanges` and `maxCallers`/`maxTests`, skipped at + computation time rather than only at serialization. Default behavior + unchanged. +- **`find_nearest_node` file-node fallback** — a file node with no + `line_start`/`line_end` could "win" as nearest match for `line: 0` + queries, returning the file instead of a symbol. Now skips nodes + without an explicit line range. +- **Python `self.`/`cls.` calls missing from the call graph** — method + calls inside a class weren't registered as `Calls` edges due to a + node-map key mismatch and an unstripped callee name; fixed, along with + `Contains` edge mislinking for same-named methods in different classes. +- **`codegraph_index_health`** (new) — diagnostics tool reporting graph + state, generation, git HEAD, file drift since last index, and other + known namespaces in one call. + ## [0.17.0] — 2026-05-25 ### Documentation Intelligence (new) diff --git a/README.md b/README.md index 3e37728..7b9ad96 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,13 @@ If it saves you time, consider [sponsoring on GitHub](https://github.com/sponsor --- +## Authors + +- [Andrey Vasilevsky](https://github.com/anvanster) — creator & maintainer +- [Aleksei Nekrasov](https://github.com/Znbiz) — contributor + +--- + ## License Apache-2.0 diff --git a/crates/codegraph-python/src/extractor.rs b/crates/codegraph-python/src/extractor.rs index 4733abc..f58e3c6 100644 --- a/crates/codegraph-python/src/extractor.rs +++ b/crates/codegraph-python/src/extractor.rs @@ -604,12 +604,32 @@ fn extract_calls_recursive( } } -/// Extract the callee name from a call's function node +/// Extract the callee name from a call's function node. +/// +/// For `self.method()`/`cls.method()` — by far the most common call shape +/// inside any method — returns the bare `method` name, not `self.method`. +/// `self`/`cls` are Python's instance/class-reference convention, not a +/// real qualifier; graph nodes are keyed by bare name (or `Class.method` +/// for the *caller* side, see `parser_impl::ir_to_graph`), so a literal +/// `"self.method"` string never matched anything and every such call was +/// silently dropped instead of becoming a same-file Calls edge. Other +/// `obj.method()` shapes keep the full dotted text — resolving those +/// would need type inference this extractor doesn't do, so falling back +/// to unresolved (as before) is still correct there. fn extract_callee_name(source: &[u8], node: Node) -> String { match node.kind() { "identifier" => node.utf8_text(source).unwrap_or("").to_string(), "attribute" => { - // Handle obj.method() or self.method() + let object_is_self_or_cls = node + .child_by_field_name("object") + .and_then(|o| o.utf8_text(source).ok()) + .is_some_and(|t| t == "self" || t == "cls"); + if object_is_self_or_cls { + if let Some(attr) = node.child_by_field_name("attribute") { + return attr.utf8_text(source).unwrap_or("").to_string(); + } + } + // Handle obj.method() — kept as the full dotted text (unresolved). node.utf8_text(source).unwrap_or("").to_string() } _ => String::new(), diff --git a/crates/codegraph-python/src/parser_impl.rs b/crates/codegraph-python/src/parser_impl.rs index 7980978..0c00592 100644 --- a/crates/codegraph-python/src/parser_impl.rs +++ b/crates/codegraph-python/src/parser_impl.rs @@ -155,6 +155,16 @@ impl PythonParser { .map_err(|e| ParserError::GraphError(e.to_string()))?; node_map.insert(func.name.clone(), func_id); + // Methods are extracted with a qualified caller name + // ("ClassName.method_name", see extractor.rs) for their own call + // sites, but were only ever registered here under the bare name + // — so `node_map.get(&call.caller)` below always missed for + // calls originating inside a method, and every such call was + // silently dropped into `unresolved_calls` instead of becoming + // a same-file Calls edge. Register the qualified name too. + if let Some(parent) = &func.parent_class { + node_map.insert(format!("{parent}.{}", func.name), func_id); + } function_ids.push(func_id); // Link function to file @@ -193,10 +203,16 @@ impl PythonParser { .map_err(|e| ParserError::GraphError(e.to_string()))?; // Methods are already added via ir.functions with parent_class set - // Just create edges from class to its methods + // Just create edges from class to its methods. Prefer the + // qualified key — two classes with a same-named method (e.g. + // `__init__`) would otherwise both resolve to whichever one + // `node_map`'s bare-name entry happened to be last written by. for method in &class.methods { - let method_name = method.name.clone(); - if let Some(&method_id) = node_map.get(&method_name) { + let qualified = format!("{}.{}", class.name, method.name); + let method_id = node_map + .get(&qualified) + .or_else(|| node_map.get(&method.name)); + if let Some(&method_id) = method_id { // Link method to class graph .add_edge(class_id, method_id, EdgeType::Contains, PropertyMap::new()) @@ -588,6 +604,51 @@ mod tests { assert_eq!(metrics.files_failed, 0); } + #[test] + fn test_call_from_inside_method_creates_local_calls_edge() { + use codegraph::EdgeType; + + // Regression test: calls made from inside a class method were never + // wired as Calls edges because the extractor emits a qualified + // caller name ("Foo.caller_method") for method-body calls, but + // `node_map` only ever registered functions under their bare name. + // `get_callers`/`get_callees` on any method silently returned + // nothing for same-file calls. + let source = "\ +class Foo: + def helper(self): + return 1 + + def caller_method(self): + return self.helper() +"; + let parser = PythonParser::new(); + let mut graph = codegraph::CodeGraph::in_memory().unwrap(); + let file_info = parser + .parse_source(source, Path::new("test.py"), &mut graph) + .unwrap(); + + let node_named = |name: &str| { + file_info + .functions + .iter() + .copied() + .find(|&id| graph.get_node(id).unwrap().properties.get_string("name") == Some(name)) + .unwrap_or_else(|| panic!("no function node named {name}")) + }; + + let helper_id = node_named("helper"); + let caller_id = node_named("caller_method"); + + let edges = graph.get_edges_between(caller_id, helper_id).unwrap(); + assert!( + edges + .iter() + .any(|&eid| graph.get_edge(eid).unwrap().edge_type == EdgeType::Calls), + "expected a Calls edge from caller_method to helper" + ); + } + #[test] fn test_implements_edge_creation() { use codegraph::{CodeGraph, EdgeType}; diff --git a/crates/codegraph-server/src/domain/call_graph.rs b/crates/codegraph-server/src/domain/call_graph.rs index e7bb5b1..fc40a33 100644 --- a/crates/codegraph-server/src/domain/call_graph.rs +++ b/crates/codegraph-server/src/domain/call_graph.rs @@ -68,6 +68,7 @@ pub(crate) struct CallGraphResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -290,6 +291,7 @@ pub(crate) async fn get_call_graph( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/callers.rs b/crates/codegraph-server/src/domain/callers.rs index 4c318f7..9f339df 100644 --- a/crates/codegraph-server/src/domain/callers.rs +++ b/crates/codegraph-server/src/domain/callers.rs @@ -36,6 +36,7 @@ pub(crate) struct CallersResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } /// Result of `get_callees`. @@ -49,6 +50,7 @@ pub(crate) struct CalleesResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -117,6 +119,7 @@ pub(crate) async fn get_callers( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } @@ -180,6 +183,7 @@ pub(crate) async fn get_callees( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/edit_context.rs b/crates/codegraph-server/src/domain/edit_context.rs index ab7e93a..da05955 100644 --- a/crates/codegraph-server/src/domain/edit_context.rs +++ b/crates/codegraph-server/src/domain/edit_context.rs @@ -46,7 +46,11 @@ pub(crate) struct EditContextSymbol { pub name: String, #[serde(rename = "type")] pub symbol_type: String, - pub code: String, + /// Absent when `includeSymbol=false` was passed — name/type/location are + /// still returned (needed to orient the caller) but the source body, + /// the single most expensive field in this response, is skipped. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, pub language: String, pub location: EditContextLocation, } @@ -141,6 +145,35 @@ pub(crate) struct EditContextError { // Domain Function // ============================================================ +/// Which sections to assemble and how many list entries to allow per +/// section. Disabling a section skips its underlying queries entirely +/// (not just its serialization) — that's where the token *and* compute +/// savings come from. +#[derive(Debug, Clone, Copy)] +pub(crate) struct EditContextOptions { + pub include_symbol_source: bool, + pub include_callers: bool, + pub include_tests: bool, + pub include_memories: bool, + pub include_recent_changes: bool, + pub max_callers: usize, + pub max_tests: usize, +} + +impl Default for EditContextOptions { + fn default() -> Self { + EditContextOptions { + include_symbol_source: true, + include_callers: true, + include_tests: true, + include_memories: true, + include_recent_changes: true, + max_callers: 10, + max_tests: 5, + } + } +} + /// Assemble comprehensive edit context for a file + line in a single call. /// /// `file_path` should be the resolved filesystem path (not a URI). @@ -158,6 +191,7 @@ pub(crate) async fn get_edit_context( uri: &str, line: u32, max_tokens: usize, + options: EditContextOptions, ) -> Result { use crate::domain::node_resolution; @@ -223,12 +257,16 @@ pub(crate) async fn get_edit_context( }; // --- Section 1: Symbol source code (budget: up to 30%) --- - let source_code_str = { + let source_code_str = if options.include_symbol_source { let g = graph.read().await; - source_code::get_symbol_source(&g, target) - .unwrap_or_else(|| "".to_string()) + Some( + source_code::get_symbol_source(&g, target) + .unwrap_or_else(|| "".to_string()), + ) + } else { + None }; - let source_tokens = source_code_str.len() / 4; + let source_tokens = source_code_str.as_ref().map(|s| s.len() / 4).unwrap_or(0); let mut budget_remaining = max_tokens.saturating_sub(source_tokens); let symbol = EditContextSymbol { @@ -253,12 +291,14 @@ pub(crate) async fn get_edit_context( // --- Section 2: Callers (budget: up to 25% of original) --- let caller_budget = max_tokens / 4; - let callers: Vec = { + let callers: Vec = if !options.include_callers { + Vec::new() + } else { let callers = query_engine.get_callers(target, 1).await; let mut caller_tokens_used = 0usize; let mut caller_list = Vec::new(); - for caller in callers.iter().take(10) { + for caller in callers.iter().take(options.max_callers) { if caller_tokens_used >= caller_budget { break; } @@ -282,7 +322,9 @@ pub(crate) async fn get_edit_context( // --- Section 3: Related tests (budget: up to 20% of original) --- let test_budget = max_tokens / 5; - let tests: Vec = { + let tests: Vec = if !options.include_tests { + Vec::new() + } else { let mut test_list = Vec::new(); let mut test_tokens_used = 0usize; let mut seen_ids = std::collections::HashSet::::new(); @@ -293,7 +335,7 @@ pub(crate) async fn get_edit_context( let tests = query_engine.find_entry_points(&entry_types).await; for test in tests.iter().take(20) { - if test_list.len() >= 5 || test_tokens_used >= test_budget { + if test_list.len() >= options.max_tests || test_tokens_used >= test_budget { break; } let callees = query_engine.get_callees(test.node_id, 3).await; @@ -314,11 +356,11 @@ pub(crate) async fn get_edit_context( } // Stage 2: Same-file test functions (if room) - if test_list.len() < 5 { + if test_list.len() < options.max_tests { let g = graph.read().await; if let Ok(file_nodes) = g.query().property("path", sym_path.clone()).execute() { for node_id in file_nodes { - if test_list.len() >= 5 || test_tokens_used >= test_budget { + if test_list.len() >= options.max_tests || test_tokens_used >= test_budget { break; } if !seen_ids.insert(node_id) { @@ -345,7 +387,9 @@ pub(crate) async fn get_edit_context( }; // --- Section 4: Memories (budget: up to 15% of original) --- - let memories: Vec = { + let memories: Vec = if !options.include_memories { + Vec::new() + } else { let search_query = if sym_path.is_empty() { name.clone() } else { @@ -392,7 +436,9 @@ pub(crate) async fn get_edit_context( }; // --- Section 5: Recent git changes (budget: up to 10% of original) --- - let recent_changes: Vec = { + let recent_changes: Vec = if !options.include_recent_changes { + Vec::new() + } else { match workspace_folders.first().cloned() { Some(ws) => { let file_path_clone = sym_path.clone(); @@ -452,7 +498,7 @@ pub(crate) async fn get_edit_context( }; // Capture section presence booleans before moving the Vecs. - let has_symbol = !source_code_str.is_empty(); + let has_symbol = source_code_str.is_some(); let has_callers = !callers.is_empty(); let has_tests = !tests.is_empty(); let has_memories = !memories.is_empty(); diff --git a/crates/codegraph-server/src/domain/impact.rs b/crates/codegraph-server/src/domain/impact.rs index 3d53841..68cf61a 100644 --- a/crates/codegraph-server/src/domain/impact.rs +++ b/crates/codegraph-server/src/domain/impact.rs @@ -90,6 +90,7 @@ pub(crate) struct ImpactResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -291,6 +292,7 @@ pub(crate) async fn analyze_impact( warnings, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/mod.rs b/crates/codegraph-server/src/domain/mod.rs index 67aa3f1..5178795 100644 --- a/crates/codegraph-server/src/domain/mod.rs +++ b/crates/codegraph-server/src/domain/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod module_summary; pub(crate) mod node_props; pub(crate) mod node_resolution; pub(crate) mod pattern_search; +pub(crate) mod probe_symbol; pub(crate) mod related_tests; pub(crate) mod source_code; pub(crate) mod symbol_info; diff --git a/crates/codegraph-server/src/domain/node_props.rs b/crates/codegraph-server/src/domain/node_props.rs index ef1739e..a37cfee 100644 --- a/crates/codegraph-server/src/domain/node_props.rs +++ b/crates/codegraph-server/src/domain/node_props.rs @@ -21,13 +21,11 @@ pub(crate) fn line_end(node: &Node) -> u32 { } /// Optional variant — returns None when neither key is present. -#[allow(dead_code)] pub(crate) fn line_start_opt(node: &Node) -> Option { line_start_opt_from_props(&node.properties) } /// Optional variant — returns None when neither key is present. -#[allow(dead_code)] pub(crate) fn line_end_opt(node: &Node) -> Option { line_end_opt_from_props(&node.properties) } diff --git a/crates/codegraph-server/src/domain/node_resolution.rs b/crates/codegraph-server/src/domain/node_resolution.rs index 11559ae..dc70acc 100644 --- a/crates/codegraph-server/src/domain/node_resolution.rs +++ b/crates/codegraph-server/src/domain/node_resolution.rs @@ -7,6 +7,52 @@ use codegraph::{CodeGraph, NodeId}; use crate::domain::node_props; +/// Compare two file paths for the "same file" relation, tolerant of one side being +/// relative (as stored by the indexer, e.g. "./crates/foo/bar.rs") and the other +/// absolute (as produced by resolving a `file://` URI from an IDE/MCP client). +/// +/// Falls back to a suffix match on path-component boundaries so `"./a/b.rs"` +/// matches `"/workspace/a/b.rs"` without requiring filesystem canonicalization. +pub(crate) fn paths_match(node_path: &str, query_path: &str) -> bool { + let node_norm = node_path.trim_start_matches("./"); + let query_norm = query_path.trim_start_matches("./"); + if node_norm.is_empty() || query_norm.is_empty() { + return false; + } + if node_norm == query_norm { + return true; + } + if let Some(rest) = query_norm.strip_suffix(node_norm) { + return rest.is_empty() || rest.ends_with('/'); + } + if let Some(rest) = node_norm.strip_suffix(query_norm) { + return rest.is_empty() || rest.ends_with('/'); + } + false +} + +/// Resolve a `uri` argument to a path string usable for graph lookups. +/// +/// MCP clients are expected to send LSP-style `file://` URIs, but plain +/// filesystem paths (relative or absolute) are also accepted — this matters +/// for `--run-tool` one-shot invocations and simpler MCP clients that don't +/// bother with URI encoding. +pub(crate) fn resolve_uri_to_path(uri: &str) -> Option { + if let Ok(url) = tower_lsp::lsp_types::Url::parse(uri) { + if let Ok(path) = url.to_file_path() { + return Some(path.to_string_lossy().to_string()); + } + // Parsed as a URL but not a file:// URI we can resolve to a path — invalid. + if url.scheme() != "file" { + return None; + } + } + if uri.is_empty() { + return None; + } + Some(uri.to_string()) +} + /// Find the nearest function/symbol node at or near a given line in a file. /// /// Strategy: @@ -20,14 +66,22 @@ pub(crate) fn find_nearest_node( target_line: u32, ) -> Option<(NodeId, bool)> { // Strategy 1: Exact containment (prefer tightest — smallest range) + // Only considers nodes with an explicit line range. Container nodes like + // `CodeFile` are often indexed without `line_start`/`line_end` at all; treating + // that absence as a `0..0` range would make them win every containment check + // for `target_line == 0` (range_size 0 beats any real symbol's range) and + // masquerade as the nearest symbol, even though they have no real source span. let mut best_exact: Option<(NodeId, u32)> = None; // (id, range_size) for (&node_id, node) in graph.nodes_iter() { - if node_props::path(node) != file_path { + if !paths_match(node_props::path(node), file_path) { continue; } - let start = node_props::line_start(node); - let end = node_props::line_end(node); + let (Some(start), Some(end)) = + (node_props::line_start_opt(node), node_props::line_end_opt(node)) + else { + continue; + }; if target_line >= start && target_line <= end { let range_size = end.saturating_sub(start); if best_exact.is_none() || range_size < best_exact.unwrap().1 { @@ -42,11 +96,16 @@ pub(crate) fn find_nearest_node( // Strategy 2: Nearest by proximity (prefer forward, penalize backward) let mut best_fallback: Option<(NodeId, i64)> = None; for (&node_id, node) in graph.nodes_iter() { - if node_props::path(node) != file_path { + if !paths_match(node_props::path(node), file_path) { continue; } - let start = node_props::line_start(node) as i64; - let end = node_props::line_end(node) as i64; + let (Some(start), Some(end)) = + (node_props::line_start_opt(node), node_props::line_end_opt(node)) + else { + continue; + }; + let start = start as i64; + let end = end as i64; let target = target_line as i64; let distance = if start > target { @@ -62,3 +121,230 @@ pub(crate) fn find_nearest_node( best_fallback.map(|(id, _)| (id, true)) } + +/// Structured reason why `find_nearest_node` (or a `uri` resolution) failed to +/// find any node, replacing the previous generic "Could not find symbol" +/// message with something an agent can act on without another round trip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SymbolNotFoundReason { + /// `uri` could not be resolved to a filesystem path at all (e.g. a + /// non-`file://` URI or an unparseable one). + InvalidUri, + /// The graph has no nodes at all — nothing has been indexed yet. + WorkspaceNotIndexed, + /// The graph has nodes, but none of them belong to the requested file. + FileNotIndexed, +} + +impl SymbolNotFoundReason { + pub(crate) fn code(self) -> &'static str { + match self { + SymbolNotFoundReason::InvalidUri => "invalid_uri", + SymbolNotFoundReason::WorkspaceNotIndexed => "workspace_not_indexed", + SymbolNotFoundReason::FileNotIndexed => "file_not_indexed", + } + } + + pub(crate) fn hint(self) -> &'static str { + match self { + SymbolNotFoundReason::InvalidUri => { + "Pass a file:// URI or a plain filesystem path (relative or absolute)." + } + SymbolNotFoundReason::WorkspaceNotIndexed => { + "Run codegraph_reindex_workspace(force=true) before querying symbols." + } + SymbolNotFoundReason::FileNotIndexed => { + "This file has no nodes in the graph yet. Run codegraph_index_files \ + for it, or codegraph_reindex_workspace(force=true) if it should \ + already be covered by the workspace root." + } + } + } + + /// Concrete next tool calls likely to resolve this failure — the same + /// guidance as `hint()`, but as machine-actionable tool names instead of + /// prose, so an agent doesn't have to parse English to pick a next step. + pub(crate) fn suggested_next_queries(self) -> &'static [&'static str] { + match self { + SymbolNotFoundReason::InvalidUri => &[], + SymbolNotFoundReason::WorkspaceNotIndexed => { + &["codegraph_reindex_workspace(force=true)"] + } + SymbolNotFoundReason::FileNotIndexed => &[ + "codegraph_index_files", + "codegraph_reindex_workspace(force=true)", + ], + } + } +} + +/// Confidence label for a resolved symbol: `"exact"` when the requested line +/// fell inside the symbol's own range, `"fallback"` when the nearest symbol +/// was substituted instead. Lets an agent decide whether to trust a response +/// without re-deriving it from `used_fallback` itself. +pub(crate) fn match_confidence(used_fallback: bool) -> &'static str { + if used_fallback { + "fallback" + } else { + "exact" + } +} + +/// Diagnose why a `uri`-based lookup found no node, for use in error responses. +pub(crate) fn diagnose_not_found(graph: &CodeGraph, uri: &str) -> SymbolNotFoundReason { + let Some(path) = resolve_uri_to_path(uri) else { + return SymbolNotFoundReason::InvalidUri; + }; + if graph.nodes_iter().next().is_none() { + return SymbolNotFoundReason::WorkspaceNotIndexed; + } + let file_has_nodes = graph + .nodes_iter() + .any(|(_, node)| paths_match(node_props::path(node), &path)); + if !file_has_nodes { + return SymbolNotFoundReason::FileNotIndexed; + } + // File has nodes but find_nearest_node still returned None — shouldn't + // happen given the fallback strategy has no distance limit, but keep a + // safe default rather than panicking on an invariant we can't fully prove. + SymbolNotFoundReason::FileNotIndexed +} + +#[cfg(test)] +mod tests { + use super::{diagnose_not_found, find_nearest_node, match_confidence, paths_match, SymbolNotFoundReason}; + use codegraph::{CodeGraph, NodeType, PropertyMap}; + + #[test] + fn suggested_next_queries_nonempty_for_actionable_reasons() { + assert!(SymbolNotFoundReason::InvalidUri + .suggested_next_queries() + .is_empty()); + assert_eq!( + SymbolNotFoundReason::WorkspaceNotIndexed.suggested_next_queries(), + &["codegraph_reindex_workspace(force=true)"] + ); + assert_eq!( + SymbolNotFoundReason::FileNotIndexed + .suggested_next_queries() + .len(), + 2 + ); + } + + #[test] + fn match_confidence_exact_vs_fallback() { + assert_eq!(match_confidence(false), "exact"); + assert_eq!(match_confidence(true), "fallback"); + } + + #[test] + fn diagnose_not_found_invalid_uri() { + let graph = CodeGraph::in_memory().unwrap(); + assert_eq!( + diagnose_not_found(&graph, "not-a-uri://???"), + SymbolNotFoundReason::InvalidUri + ); + } + + #[test] + fn diagnose_not_found_empty_workspace() { + let graph = CodeGraph::in_memory().unwrap(); + assert_eq!( + diagnose_not_found(&graph, "/workspace/src/lib.rs"), + SymbolNotFoundReason::WorkspaceNotIndexed + ); + } + + #[test] + fn diagnose_not_found_file_not_indexed() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new() + .with("name", "other") + .with("path", "./crates/foo/other.rs"); + graph.add_node(NodeType::Function, props).unwrap(); + + assert_eq!( + diagnose_not_found(&graph, "/workspace/crates/foo/missing.rs"), + SymbolNotFoundReason::FileNotIndexed + ); + } + + #[test] + fn find_nearest_node_skips_codefile_node_without_line_range() { + // Regression test: a `CodeFile` node indexed without `line_start`/`line_end` + // (e.g. Python's module node, see codegraph-python/src/parser_impl.rs) must + // not shadow a real function when querying line 0 — its absent range used + // to default to 0..0, which trivially "contains" line 0 with the smallest + // possible range and won the containment check over any real symbol. + let mut graph = CodeGraph::in_memory().unwrap(); + let file_props = PropertyMap::new() + .with("name", "init_arch_workflow") + .with("path", "./app/services/init_arch_workflow.py") + .with("language", "python"); + graph.add_node(NodeType::CodeFile, file_props).unwrap(); + + let fn_props = PropertyMap::new() + .with("name", "init_arch_workflow") + .with("path", "./app/services/init_arch_workflow.py") + .with("line_start", "1") + .with("line_end", "40"); + let fn_id = graph.add_node(NodeType::Function, fn_props).unwrap(); + + let (node_id, used_fallback) = + find_nearest_node(&graph, "./app/services/init_arch_workflow.py", 0) + .expect("expected a match"); + assert_eq!(node_id, fn_id); + assert!(used_fallback, "line 0 is outside the function's own range"); + } + + #[test] + fn find_nearest_node_returns_none_when_only_codefile_node_present() { + // If a file has no indexed symbols at all — only its `CodeFile` node — the + // caller should get a proper "not found" instead of a fake match on the + // file node's fabricated 0..0 range. + let mut graph = CodeGraph::in_memory().unwrap(); + let file_props = PropertyMap::new() + .with("name", "empty_module") + .with("path", "./app/empty_module.py") + .with("language", "python"); + graph.add_node(NodeType::CodeFile, file_props).unwrap(); + + assert!(find_nearest_node(&graph, "./app/empty_module.py", 0).is_none()); + } + + #[test] + fn identical_paths_match() { + assert!(paths_match("./crates/foo/bar.rs", "./crates/foo/bar.rs")); + assert!(paths_match("crates/foo/bar.rs", "crates/foo/bar.rs")); + } + + #[test] + fn relative_matches_absolute_on_boundary() { + assert!(paths_match( + "./crates/foo/bar.rs", + "/workspace/crates/foo/bar.rs" + )); + assert!(paths_match( + "/workspace/crates/foo/bar.rs", + "./crates/foo/bar.rs" + )); + } + + #[test] + fn does_not_match_on_partial_component() { + // "bar.rs" must not match "notbar.rs" — boundary must fall on a '/' + assert!(!paths_match("./crates/foo/notbar.rs", "bar.rs")); + assert!(!paths_match( + "./crates/other/bar.rs", + "./crates/foo/bar.rs" + )); + } + + #[test] + fn empty_paths_never_match() { + assert!(!paths_match("", "")); + assert!(!paths_match("./crates/foo/bar.rs", "")); + assert!(!paths_match("", "./crates/foo/bar.rs")); + } +} diff --git a/crates/codegraph-server/src/domain/probe_symbol.rs b/crates/codegraph-server/src/domain/probe_symbol.rs new file mode 100644 index 0000000..cb97ff0 --- /dev/null +++ b/crates/codegraph-server/src/domain/probe_symbol.rs @@ -0,0 +1,89 @@ +// Copyright 2025-2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +//! Cheap symbol probe — transport-agnostic. +//! +//! A lightweight companion to `get_symbol_info`/`get_detailed_symbol` for +//! agents that only need to confirm "did I land on the right symbol?" +//! before paying for a heavier, full-context call. + +use crate::domain::{node_props, node_resolution}; +use codegraph::{CodeGraph, NodeId}; +use serde::Serialize; + +/// Result of `codegraph_probe_symbol`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ProbeSymbolResult { + pub name: String, + #[serde(rename = "type")] + pub symbol_type: String, + pub node_id: String, + pub uri: String, + pub line_start: i64, + pub line_end: i64, + pub used_fallback: bool, + pub match_confidence: &'static str, +} + +/// Build a probe result for an already-resolved node. Cheap by construction: +/// only reads properties already held in memory, no source/callers/callees +/// queries. +pub(crate) fn probe_symbol( + graph: &CodeGraph, + node_id: NodeId, + used_fallback: bool, +) -> Option { + let node = graph.get_node(node_id).ok()?; + Some(ProbeSymbolResult { + name: node_props::name(node).to_string(), + symbol_type: format!("{:?}", node.node_type).to_lowercase(), + node_id: node_id.to_string(), + uri: node_props::path(node).to_string(), + line_start: node_props::line_start(node) as i64, + line_end: node_props::line_end(node) as i64, + used_fallback, + match_confidence: node_resolution::match_confidence(used_fallback), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codegraph::{NodeType, PropertyMap}; + + #[test] + fn probes_a_resolved_node() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new() + .with("name", "compile_navigation") + .with("path", "./a/b.py") + .with("line_start", 122) + .with("line_end", 154); + let id = graph.add_node(NodeType::Function, props).unwrap(); + + let result = probe_symbol(&graph, id, false).unwrap(); + assert_eq!(result.name, "compile_navigation"); + assert_eq!(result.symbol_type, "function"); + assert_eq!(result.line_start, 122); + assert_eq!(result.line_end, 154); + assert!(!result.used_fallback); + assert_eq!(result.match_confidence, "exact"); + } + + #[test] + fn probe_marks_fallback_confidence() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new().with("name", "f").with("path", "./a.py"); + let id = graph.add_node(NodeType::Function, props).unwrap(); + + let result = probe_symbol(&graph, id, true).unwrap(); + assert!(result.used_fallback); + assert_eq!(result.match_confidence, "fallback"); + } + + #[test] + fn probe_returns_none_for_missing_node() { + let graph = CodeGraph::in_memory().unwrap(); + assert!(probe_symbol(&graph, 999_999u64, false).is_none()); + } +} diff --git a/crates/codegraph-server/src/domain/symbol_info.rs b/crates/codegraph-server/src/domain/symbol_info.rs index c7d11e4..e98b91f 100644 --- a/crates/codegraph-server/src/domain/symbol_info.rs +++ b/crates/codegraph-server/src/domain/symbol_info.rs @@ -40,24 +40,79 @@ pub(crate) struct SymbolInfoResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, +} + +/// Symbol metadata for `get_detailed_symbol`, deliberately *without* +/// `callers`/`callees` — those live once, at the top level of +/// `DetailedSymbolResult`, instead of being duplicated here too. Both this +/// struct and the top-level fields used to be populated from independent +/// depth-1 queries that returned identical data. +#[derive(Debug, Serialize)] +pub(crate) struct DetailedSymbolMeta { + pub symbol: SymbolInfo, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dependents: Vec, + pub complexity: Option, + pub lines_of_code: usize, + pub has_tests: bool, + pub is_public: bool, + pub is_deprecated: bool, + pub reference_count: usize, +} + +impl From for DetailedSymbolMeta { + fn from(info: DetailedSymbolInfo) -> Self { + DetailedSymbolMeta { + symbol: info.symbol, + dependencies: info.dependencies, + dependents: info.dependents, + complexity: info.complexity, + lines_of_code: info.lines_of_code, + has_tests: info.has_tests, + is_public: info.is_public, + is_deprecated: info.is_deprecated, + reference_count: info.reference_count, + } + } } /// Result of `get_detailed_symbol`. #[derive(Debug, Serialize)] pub(crate) struct DetailedSymbolResult { - /// Full symbol info (serializes as a nested object matching `DetailedSymbolInfo` shape). + /// Symbol metadata (name, kind, signature, complexity, ... — no callers/callees). #[serde(skip_serializing_if = "Option::is_none")] - pub symbol: Option, + pub symbol: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub callers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub callees: Option>, + /// Present only when `compact=true` truncated the callers list. + #[serde(skip_serializing_if = "Option::is_none")] + pub callers_truncated: Option, + /// Present only when `compact=true` truncated the callees list. + #[serde(skip_serializing_if = "Option::is_none")] + pub callees_truncated: Option, #[serde(skip_serializing_if = "Option::is_none")] pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, +} + +/// Cap `items` to `max` entries in place, returning how many were dropped. +fn truncate_compact(items: &mut Vec, max: usize) -> Option { + if items.len() > max { + let dropped = items.len() - max; + items.truncate(max); + Some(dropped) + } else { + None + } } // ============================================================ @@ -117,12 +172,17 @@ pub(crate) async fn get_symbol_info( reference_count: info.reference_count, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), }) } /// Get detailed symbol info: basic info + optional source + callers + callees. /// /// Returns `DetailedSymbolResult`. Shape matches the MCP codegraph_get_detailed_symbol response. +/// +/// `compact`, when true, caps `callers`/`callees` at 5 entries each (with +/// `callers_truncated`/`callees_truncated` reporting how many were dropped) — +/// for symbols with hundreds of callers this is the dominant cost driver. #[allow(clippy::too_many_arguments)] pub(crate) async fn get_detailed_symbol( graph: &RwLock, @@ -131,16 +191,19 @@ pub(crate) async fn get_detailed_symbol( include_source: bool, include_callers: bool, include_callees: bool, + compact: bool, used_fallback: bool, requested_line: Option, ) -> DetailedSymbolResult { - // Get basic symbol info - let (symbol, symbol_name) = if let Some(info) = query_engine.get_symbol_info(node_id).await { - let name = info.symbol.name.clone(); - (Some(info), name) - } else { - (None, String::new()) - }; + // Single depth-1 fetch of symbol info + callers/callees — reused for both + // the top-level `callers`/`callees` fields and the symbol metadata, so we + // don't run get_call_chain twice for the same data (see DetailedSymbolMeta). + let info = query_engine.get_symbol_info(node_id).await; + + let symbol_name = info + .as_ref() + .map(|i| i.symbol.name.clone()) + .unwrap_or_default(); let (used_fallback_field, fallback_message) = if used_fallback { ( @@ -163,26 +226,104 @@ pub(crate) async fn get_detailed_symbol( None }; - // Get callers if requested - let callers = if include_callers { - Some(query_engine.get_callers(node_id, 1).await) + const COMPACT_MAX_CALLS: usize = 5; + + let (mut callers, mut callees) = match &info { + Some(i) => (Some(i.callers.clone()), Some(i.callees.clone())), + None => (None, None), + }; + if !include_callers { + callers = None; + } + if !include_callees { + callees = None; + } + + let callers_truncated = if compact { + callers + .as_mut() + .and_then(|c| truncate_compact(c, COMPACT_MAX_CALLS)) } else { None }; - - // Get callees if requested - let callees = if include_callees { - Some(query_engine.get_callees(node_id, 1).await) + let callees_truncated = if compact { + callees + .as_mut() + .and_then(|c| truncate_compact(c, COMPACT_MAX_CALLS)) } else { None }; + let symbol = info.map(DetailedSymbolMeta::from); + DetailedSymbolResult { symbol, source, callers, callees, + callers_truncated, + callees_truncated, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), + } +} + +#[cfg(test)] +mod tests { + use super::truncate_compact; + use crate::ai_query::{CallInfo, SymbolInfo, SymbolLocation}; + + fn dummy_call(n: u32) -> CallInfo { + CallInfo { + node_id: n as u64, + symbol: SymbolInfo { + name: format!("caller_{n}"), + kind: "function".to_string(), + location: SymbolLocation { + file: "a.rs".to_string(), + line: n, + column: 0, + end_line: n, + end_column: 0, + }, + signature: None, + docstring: None, + is_public: true, + visibility: "public".to_string(), + }, + call_site: SymbolLocation { + file: "a.rs".to_string(), + line: n, + column: 0, + end_line: n, + end_column: 0, + }, + depth: 1, + via_ops_struct: None, + ops_field: None, + } + } + + #[test] + fn truncate_compact_leaves_short_lists_untouched() { + let mut items = vec![dummy_call(1), dummy_call(2)]; + assert_eq!(truncate_compact(&mut items, 5), None); + assert_eq!(items.len(), 2); + } + + #[test] + fn truncate_compact_caps_and_reports_dropped_count() { + let mut items: Vec = (0..8).map(dummy_call).collect(); + let dropped = truncate_compact(&mut items, 5); + assert_eq!(dropped, Some(3)); + assert_eq!(items.len(), 5); + } + + #[test] + fn truncate_compact_exact_length_is_not_truncated() { + let mut items: Vec = (0..5).map(dummy_call).collect(); + assert_eq!(truncate_compact(&mut items, 5), None); + assert_eq!(items.len(), 5); } } diff --git a/crates/codegraph-server/src/handlers/ai_context.rs b/crates/codegraph-server/src/handlers/ai_context.rs index 58f6695..b1ff51a 100644 --- a/crates/codegraph-server/src/handlers/ai_context.rs +++ b/crates/codegraph-server/src/handlers/ai_context.rs @@ -7,7 +7,7 @@ use crate::backend::CodeGraphBackend; use crate::domain::ai_context::AiContextResult; use serde::{Deserialize, Serialize}; use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::{Position, Range, Url}; +use tower_lsp::lsp_types::{Position, Range}; // ========================================== // AI Context Request Types (public — used externally) @@ -51,12 +51,8 @@ impl CodeGraphBackend { &self, params: AIContextParams, ) -> Result { - let uri = Url::parse(¶ms.uri) - .map_err(|_| tower_lsp::jsonrpc::Error::invalid_params("Invalid URI"))?; - - let path = uri - .to_file_path() - .map_err(|_| tower_lsp::jsonrpc::Error::invalid_params("Invalid file path"))?; + let path_str = crate::domain::node_resolution::resolve_uri_to_path(¶ms.uri) + .ok_or_else(|| tower_lsp::jsonrpc::Error::invalid_params("Invalid URI"))?; let line = if let Some(l) = params.line { l @@ -68,7 +64,6 @@ impl CodeGraphBackend { let intent = params.intent.as_deref().unwrap_or("explain"); let max_tokens = params.max_tokens.unwrap_or(4000); - let path_str = path.to_string_lossy().to_string(); let graph = self.graph.read().await; diff --git a/crates/codegraph-server/src/handlers/ai_query.rs b/crates/codegraph-server/src/handlers/ai_query.rs index 9c1b745..0635327 100644 --- a/crates/codegraph-server/src/handlers/ai_query.rs +++ b/crates/codegraph-server/src/handlers/ai_query.rs @@ -719,6 +719,7 @@ impl CodeGraphBackend { include_source, include_callers, include_callees, + false, // compact: LSP callers don't yet expose this knob used_fallback, params.line, ) diff --git a/crates/codegraph-server/src/mcp/engine.rs b/crates/codegraph-server/src/mcp/engine.rs index a94ffab..cae24ea 100644 --- a/crates/codegraph-server/src/mcp/engine.rs +++ b/crates/codegraph-server/src/mcp/engine.rs @@ -219,7 +219,7 @@ mod imp { let shared_engine = { let mut sys = sysinfo::System::new(); sys.refresh_memory(); - if sys.available_memory() < 1_500_000_000 { + if crate::memory::should_skip_embeddings_for_available_memory(sys.available_memory()) { tracing::warn!("Engine: <1.5 GB free — running graph-only (no shared model)"); None } else { diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index e244e72..de29b93 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -2057,7 +2057,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let depth = args .get("depth") .and_then(|v| v.as_u64()) @@ -2065,8 +2065,8 @@ impl McpServer { .unwrap_or(1); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2088,10 +2088,14 @@ impl McpServer { .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "callers": [], - "message": "Could not find starting node. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "callers": [], + "message": "Could not find starting node. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2101,7 +2105,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let depth = args .get("depth") .and_then(|v| v.as_u64()) @@ -2109,8 +2113,8 @@ impl McpServer { .unwrap_or(1); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2132,10 +2136,14 @@ impl McpServer { .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "callees": [], - "message": "Could not find starting node. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "callees": [], + "message": "Could not find starting node. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2146,7 +2154,7 @@ impl McpServer { .get("startNodeId") .or_else(|| args.get("nodeId")) .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let direction_str = args .get("direction") .and_then(|v| v.as_str()) @@ -2164,8 +2172,8 @@ impl McpServer { .unwrap_or(100); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2271,11 +2279,52 @@ impl McpServer { Ok(response) } } else { - Ok(serde_json::json!({ - "nodes": [], - "edges": [], - "message": "Could not find starting node. Provide either startNodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "nodes": [], + "edges": [], + "message": "Could not find starting node. Provide either startNodeId or uri+line." + }), + reason, + )) + } + } + + "codegraph_probe_symbol" => { + let uri = args.get("uri").and_then(|v| v.as_str()); + let line = args.get("line").and_then(|v| v.as_u64()).map(|v| v as u32); + let node_id = args + .get("nodeId") + .or_else(|| args.get("node_id")) + .and_then(arg_node_id); + + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) + } else if let (Some(u), Some(l)) = (uri, line) { + match self.find_nearest_node_with_fallback(u, l).await { + Some((id, fallback)) => (Some(id), fallback), + None => (None, false), + } + } else { + (None, false) + }; + + if let Some(node_id) = target_node { + let graph = self.backend.graph.read().await; + match crate::domain::probe_symbol::probe_symbol(&graph, node_id, used_fallback) + { + Some(result) => Ok(serde_json::to_value(&result).unwrap_or_default()), + None => Ok(serde_json::json!({ "error": "Symbol not found" })), + } + } else { + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2285,7 +2334,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let include_refs = args .get("includeReferences") .or_else(|| args.get("include_references")) @@ -2293,8 +2342,8 @@ impl McpServer { .unwrap_or(false); // Use fallback for uri+line, exact match for node_id - let (target_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2321,9 +2370,13 @@ impl McpServer { })), } } else { - Ok(serde_json::json!({ - "error": "Could not find symbol. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2333,7 +2386,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let include_source = args .get("includeSource") .or_else(|| args.get("include_source")) @@ -2349,10 +2402,14 @@ impl McpServer { .or_else(|| args.get("include_callees")) .and_then(|v| v.as_bool()) .unwrap_or(true); + let compact = args + .get("compact") + .and_then(|v| v.as_bool()) + .unwrap_or(false); // Use fallback for uri+line, exact match for node_id - let (target_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2370,15 +2427,20 @@ impl McpServer { include_source, include_callers, include_callees, + compact, used_fallback, line, ) .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "error": "Could not find symbol. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2408,12 +2470,8 @@ impl McpServer { .unwrap_or(false); let typed_result = { - let url = tower_lsp::lsp_types::Url::parse(uri) - .map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let graph = self.backend.graph.read().await; crate::domain::dependency_graph::get_dependency_graph( &graph, &path_str, depth, direction, @@ -2480,11 +2538,17 @@ impl McpServer { .await; serde_json::to_value(&typed).unwrap_or_default() } - None => serde_json::json!({ - "nodes": [], - "edges": [], - "message": "Could not find symbol at location" - }), + None => { + let reason = self.diagnose_not_found_reason(Some(uri)).await; + Self::attach_not_found_reason( + serde_json::json!({ + "nodes": [], + "edges": [], + "message": "Could not find symbol at location" + }), + reason, + ) + } }; if summary { @@ -2567,11 +2631,17 @@ impl McpServer { .await; serde_json::to_value(&typed).unwrap_or_default() } - None => serde_json::json!({ - "impacted": [], - "risk_level": "unknown", - "message": "Could not find symbol at location" - }), + None => { + let reason = self.diagnose_not_found_reason(Some(uri)).await; + Self::attach_not_found_reason( + serde_json::json!({ + "impacted": [], + "risk_level": "unknown", + "message": "Could not find symbol at location" + }), + reason, + ) + } }; if summary { @@ -2633,19 +2703,21 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(4000); - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or_else(|| "Invalid URI".to_string())?; + let path = std::path::Path::new(&path_str); let graph = self.backend.graph.read().await; let result = crate::domain::ai_context::get_ai_context( &graph, &path_str, line, intent, max_tokens, ) .ok_or_else(|| { - format!("No symbols found in '{uri}'. Try indexing the workspace first.") + let reason = crate::domain::node_resolution::diagnose_not_found(&graph, uri); + format!( + "No symbols found in '{uri}' ({}). {}", + reason.code(), + reason.hint() + ) })?; let mut json = serde_json::to_value(result).map_err(|e| e.to_string())?; @@ -2713,11 +2785,51 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(8000); - let file_path = tower_lsp::lsp_types::Url::parse(uri) - .ok() - .and_then(|u| u.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); + let defaults = crate::domain::edit_context::EditContextOptions::default(); + let bool_arg = |key: &str, snake: &str, default: bool| { + args.get(key) + .or_else(|| args.get(snake)) + .and_then(|v| v.as_bool()) + .unwrap_or(default) + }; + let options = crate::domain::edit_context::EditContextOptions { + include_symbol_source: bool_arg( + "includeSymbol", + "include_symbol", + defaults.include_symbol_source, + ), + include_callers: bool_arg( + "includeCallers", + "include_callers", + defaults.include_callers, + ), + include_tests: bool_arg("includeTests", "include_tests", defaults.include_tests), + include_memories: bool_arg( + "includeMemories", + "include_memories", + defaults.include_memories, + ), + include_recent_changes: bool_arg( + "includeRecentChanges", + "include_recent_changes", + defaults.include_recent_changes, + ), + max_callers: args + .get("maxCallers") + .or_else(|| args.get("max_callers")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(defaults.max_callers), + max_tests: args + .get("maxTests") + .or_else(|| args.get("max_tests")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(defaults.max_tests), + }; + + let file_path = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let result = crate::domain::edit_context::get_edit_context( &self.backend.graph, &self.backend.query_engine, @@ -2727,6 +2839,7 @@ impl McpServer { uri, line, max_tokens, + options, ) .await; Ok(match result { @@ -2754,12 +2867,8 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(5); - let anchor_path: Option = uri.and_then(|u| { - tower_lsp::lsp_types::Url::parse(u) - .ok() - .and_then(|parsed| parsed.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()) - }); + let anchor_path: Option = + uri.and_then(crate::domain::node_resolution::resolve_uri_to_path); let result = crate::domain::curated_context::get_curated_context( &self.backend.graph, &self.backend.query_engine, @@ -2792,46 +2901,40 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(10); - // Resolve file path - let url = match tower_lsp::lsp_types::Url::parse(uri) { - Ok(u) => u, - Err(_) => { + // Resolve file path (accepts file:// URIs and plain paths) + let path_str = match crate::domain::node_resolution::resolve_uri_to_path(uri) { + Some(p) => p, + None => { return Ok(serde_json::json!({ "tests": [], "message": "Invalid URI" })) } }; - let file_path = match url.to_file_path() { - Ok(p) => p, - Err(_) => { - return Ok(serde_json::json!({ - "tests": [], - "message": "Invalid file path" - })) - } - }; - let path_str = file_path.to_string_lossy().to_string(); - // Resolve target node (with fallback to nearest symbol) - let (target_node_id, used_fallback, symbol_name) = + // Resolve target node (with fallback to nearest symbol). Prefer the + // node's own stored `path` property over the resolved URI for the + // exact-match query below, since the graph may index files under a + // relative path while the resolved URI is absolute (or vice versa). + let (target_node_id, used_fallback, symbol_name, resolved_path) = match self.find_nearest_node_with_fallback(uri, line).await { Some((id, fallback)) => { - let name = { - let graph = self.backend.graph.read().await; - graph - .get_node(id) - .ok() - .map(|n| node_props::name(n).to_string()) - .unwrap_or_default() - }; - (Some(id), fallback, name) + let graph = self.backend.graph.read().await; + let node = graph.get_node(id).ok(); + let name = node + .map(|n| node_props::name(n).to_string()) + .unwrap_or_default(); + let node_path = node + .map(|n| node_props::path(n).to_string()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| path_str.clone()); + (Some(id), fallback, name, node_path) } - None => (None, false, String::new()), + None => (None, false, String::new(), path_str.clone()), }; let params = crate::domain::related_tests::FindRelatedTestsParams { - path: path_str.clone(), + path: resolved_path, target_node_id, limit, }; @@ -2903,18 +3006,19 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let graph = self.backend.graph.read().await; - let path_str = path.to_string_lossy().to_string(); - let file_nodes = graph - .query() - .property("path", path_str) - .execute() - .unwrap_or_default(); + let file_nodes: Vec = graph + .nodes_iter() + .filter(|(_, node)| { + crate::domain::node_resolution::paths_match( + node_props::path(node), + &path_str, + ) + }) + .map(|(&id, _)| id) + .collect(); let result = crate::handlers::metrics::analyze_file_complexity( &graph, &file_nodes, @@ -3132,12 +3236,8 @@ impl McpServer { .unwrap_or(5); // Find code nodes at the given location and search for related memories - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = + crate::domain::node_resolution::resolve_uri_to_path(uri).ok_or("Invalid URI")?; // Search for memories related to this file let current_only = args @@ -3323,48 +3423,164 @@ impl McpServer { // ==================== Admin Tools ==================== "codegraph_reindex_workspace" => { let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); - tracing::info!("Reindexing workspace (force={})...", force); - if force { - // Force: clear graph and hash cache for full rebuild - { - let mut graph = self.backend.graph.write().await; - *graph = codegraph::CodeGraph::in_memory() - .map_err(|e| format!("Failed to create new graph: {}", e))?; + let (total, parsed, node_count_after) = self.do_reindex_workspace(force).await?; + let mut degraded = total > 0 && node_count_after == 0; + + // A soft (force=false) reindex that left the graph empty is almost + // always stale hash-cache/state, not a real parsing failure — the + // same situation `force=true` exists to fix. Auto-retry once so + // callers don't have to learn "degraded means call me again with + // force=true" the hard way. + let (total, parsed, node_count_after, auto_retried) = if degraded && !force { + tracing::warn!( + "Soft reindex left the graph empty ({} files processed, 0 nodes) — \ + auto-retrying with force=true", + total + ); + let (total2, parsed2, node_count_after2) = + self.do_reindex_workspace(true).await?; + degraded = total2 > 0 && node_count_after2 == 0; + (total2, parsed2, node_count_after2, true) + } else { + (total, parsed, node_count_after, false) + }; + + let status = if degraded { "degraded" } else { "success" }; + let message = if degraded && auto_retried { + format!( + "Reindex reported {} files processed, but the graph has 0 nodes afterwards \ + even after an automatic force=true retry. The workspace root, \ + .codegraphignore, or --max-files may be excluding everything.", + total + ) + } else if degraded { + format!( + "Reindex reported {} files processed, but the graph has 0 nodes afterwards \ + even with force=true. The workspace root, .codegraphignore, or \ + --max-files may be excluding everything.", + total + ) + } else if auto_retried { + format!( + "Soft reindex found a stale/empty graph; auto-retried with force=true. \ + Reindexed {} files ({} changed, {} skipped)", + total, + parsed, + total - parsed + ) + } else { + format!( + "Reindexed {} files ({} changed, {} skipped)", + total, + parsed, + total - parsed + ) + }; + + let mut response = serde_json::json!({ + "status": status, + "message": message, + "files_indexed": total, + "files_parsed": parsed, + "files_skipped": total - parsed, + "node_count_after": node_count_after, + "auto_retried_with_force": auto_retried + }); + if degraded { + if let Some(obj) = response.as_object_mut() { + obj.insert( + "suggested_next_queries".to_string(), + serde_json::json!([ + "codegraph_index_health — inspect workspace_root, .codegraphignore \ + exclusions, and files_changed_since_index before retrying", + ]), + ); } - self.backend.index_state.lock().await.clear(); } - // else: incremental — index_file skips unchanged files via hash cache + Ok(response) + } - // Reindex the workspace - let (total, parsed) = self.backend.index_workspace().await; - tracing::info!( - "Reindexed: {} total, {} parsed, {} skipped", - total, - parsed, - total - parsed - ); + // ==================== Index Health ==================== + "codegraph_index_health" => { + let (node_count, edge_count) = { + let graph = self.backend.graph.read().await; + (graph.node_count(), graph.edge_count()) + }; - // Embed any new/changed symbols so semantic search reflects the reindex. - if !self.backend.graph_only { - self.backend.query_engine.embed_missing_symbols().await; - self.backend.query_engine.prune_orphan_vectors().await; - if let Err(e) = self - .backend - .query_engine - .save_symbol_vectors(&self.backend.project_slug) - .await - { - tracing::warn!("Failed to persist vectors after reindex: {}", e); + let workspace_root = self + .backend + .workspace_folders + .first() + .map(|p| p.display().to_string()); + + let workspace_revision_hint = self + .backend + .workspace_folders + .first() + .and_then(|ws| crate::git_mining::GitExecutor::new(ws).ok()) + .and_then(|g| g.head_commit().ok()) + .map(|h| h[..8.min(h.len())].to_string()); + + // Cheap staleness check: re-hash only files we already know + // about (no directory walk) and count content drift. Doesn't + // catch brand-new/deleted files — that needs a full walk, + // which is what reindex itself does; this stays cheap on + // purpose so it's safe to call before every risky operation. + let files_changed_since_index = { + let state = self.backend.index_state.lock().await; + state + .all_hashes() + .iter() + .filter(|(path, &stored_hash)| { + match std::fs::read(path) { + Ok(content) => { + crate::indexer::Indexer::hash_content(&content) != stored_hash + } + Err(_) => true, // file removed/unreadable since indexing + } + }) + .count() + }; + let files_tracked = self.backend.index_state.lock().await.len(); + + let registry = McpBackend::list_indexed_projects().unwrap_or_default(); + let (index_generated_at, other_namespaces): (Option, Vec) = { + let mut generated_at = None; + let mut others = Vec::new(); + for project in registry { + let slug = project.get("slug").and_then(|v| v.as_str()).unwrap_or(""); + if slug == self.backend.project_slug { + generated_at = project.get("last_indexed").and_then(|v| v.as_u64()); + } else { + others.push(project); + } } + (generated_at, others) + }; + + let potentially_stale = node_count == 0 || files_changed_since_index > 0; + + let mut suggested_next_queries: Vec<&str> = Vec::new(); + if node_count == 0 { + suggested_next_queries.push("codegraph_reindex_workspace(force=true)"); + } else if files_changed_since_index > 0 { + suggested_next_queries.push("codegraph_reindex_workspace(force=false)"); } Ok(serde_json::json!({ - "status": "success", - "message": format!("Reindexed {} files ({} changed, {} skipped)", total, parsed, total - parsed), - "files_indexed": total, - "files_parsed": parsed, - "files_skipped": total - parsed + "namespace": self.backend.project_slug, + "workspace_root": workspace_root, + "node_count": node_count, + "edge_count": edge_count, + "generation": crate::memory::graph_db_generation(), + "index_generated_at": index_generated_at, + "workspace_revision_hint": workspace_revision_hint, + "files_tracked": files_tracked, + "files_changed_since_index": files_changed_since_index, + "potentially_stale": potentially_stale, + "other_namespaces": other_namespaces, + "suggested_next_queries": suggested_next_queries, })) } @@ -4615,9 +4831,7 @@ impl McpServer { let file_path: Option = args .get("uri") .and_then(|v| v.as_str()) - .and_then(|uri| tower_lsp::lsp_types::Url::parse(uri).ok()) - .and_then(|url| url.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()); + .and_then(crate::domain::node_resolution::resolve_uri_to_path); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; let typed_result = { @@ -4724,6 +4938,49 @@ impl McpServer { } /// Find a node at location with broader fallback, returning whether fallback was used. + /// Reindex the workspace, optionally clearing the graph/hash-cache first. + /// + /// Returns `(files_indexed, files_parsed, node_count_after)`. + async fn do_reindex_workspace(&self, force: bool) -> Result<(usize, usize, usize), String> { + tracing::info!("Reindexing workspace (force={})...", force); + + if force { + // Force: clear graph and hash cache for full rebuild + { + let mut graph = self.backend.graph.write().await; + *graph = codegraph::CodeGraph::in_memory() + .map_err(|e| format!("Failed to create new graph: {}", e))?; + } + self.backend.index_state.lock().await.clear(); + } + // else: incremental — index_file skips unchanged files via hash cache + + let (total, parsed) = self.backend.index_workspace().await; + tracing::info!( + "Reindexed: {} total, {} parsed, {} skipped", + total, + parsed, + total - parsed + ); + + // Embed any new/changed symbols so semantic search reflects the reindex. + if !self.backend.graph_only { + self.backend.query_engine.embed_missing_symbols().await; + self.backend.query_engine.prune_orphan_vectors().await; + if let Err(e) = self + .backend + .query_engine + .save_symbol_vectors(&self.backend.project_slug) + .await + { + tracing::warn!("Failed to persist vectors after reindex: {}", e); + } + } + + let node_count_after = self.backend.graph.read().await.node_count(); + Ok((total, parsed, node_count_after)) + } + /// /// Strategy: /// 1. First try exact match (line within symbol's range) @@ -4735,13 +4992,44 @@ impl McpServer { uri: &str, line: u32, ) -> Option<(codegraph::NodeId, bool)> { - let url = tower_lsp::lsp_types::Url::parse(uri).ok()?; - let path = url.to_file_path().ok()?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri)?; let graph = self.backend.graph.read().await; crate::domain::node_resolution::find_nearest_node(&graph, &path_str, line) } + /// Diagnose why a `uri`-based lookup found nothing, for use in "not found" + /// error responses. Returns `None` if no `uri` was given (e.g. a bad + /// `nodeId` alone doesn't carry enough information to diagnose). + async fn diagnose_not_found_reason( + &self, + uri: Option<&str>, + ) -> Option { + let uri = uri?; + let graph = self.backend.graph.read().await; + Some(crate::domain::node_resolution::diagnose_not_found( + &graph, uri, + )) + } + + /// Merge a diagnosed not-found reason (if any) into an error response object. + fn attach_not_found_reason( + mut value: Value, + reason: Option, + ) -> Value { + if let (Some(reason), Some(obj)) = (reason, value.as_object_mut()) { + obj.insert("reason".to_string(), serde_json::json!(reason.code())); + obj.insert("hint".to_string(), serde_json::json!(reason.hint())); + let suggestions = reason.suggested_next_queries(); + if !suggestions.is_empty() { + obj.insert( + "suggested_next_queries".to_string(), + serde_json::json!(suggestions), + ); + } + } + value + } + /// Build a memory node from parameters fn build_memory_node( &self, @@ -4910,10 +5198,9 @@ impl McpServer { } } -/// Parse a string into a NodeId -fn parse_node_id(s: &str) -> Option { - // NodeId is u64 in codegraph - s.parse::().ok() +/// Read a nodeId argument that MCP clients may send as either a JSON string or a JSON number. +fn arg_node_id(v: &serde_json::Value) -> Option { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())) } #[cfg(test)] diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index 6b61ecd..46d4aee 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -59,6 +59,7 @@ pub fn tool_in_profile(name: &str, profile: ToolProfile) -> bool { Core => matches!( name, "codegraph_symbol_search" + | "codegraph_probe_symbol" | "codegraph_get_symbol_info" | "codegraph_get_detailed_symbol" | "codegraph_get_ai_context" @@ -103,7 +104,7 @@ pub fn tool_in_profile(name: &str, profile: ToolProfile) -> bool { /// Get all available CodeGraph tools pub fn get_all_tools() -> Vec { vec![ - // Analysis Tools (11) + // Analysis Tools (12) get_dependency_graph_tool(), get_call_graph_tool(), analyze_impact_tool(), @@ -112,6 +113,7 @@ pub fn get_all_tools() -> Vec { get_curated_context_tool(), find_related_tests_tool(), get_symbol_info_tool(), + probe_symbol_tool(), analyze_complexity_tool(), get_module_summary_tool(), find_circular_deps_tool(), @@ -140,10 +142,11 @@ pub fn get_all_tools() -> Vec { find_dead_imports_tool(), // Ops Struct Tools (1) find_implementors_tool(), - // Admin Tools (3) + // Admin Tools (4) reindex_workspace_tool(), index_files_tool(), index_directory_tool(), + index_health_tool(), // PR / Change Analysis (1) pr_context_tool(), // Docs Tools (7) @@ -401,10 +404,43 @@ fn get_edit_context_tool() -> Tool { Some(8000.0), ), ); + properties.insert( + "includeSymbol".to_string(), + boolean_prop( + "Include the target symbol's source code (name/type/location are always \ + returned regardless). Disable when you already have the source and \ + only need callers/tests/memories/git history.", + true, + ), + ); + properties.insert( + "includeCallers".to_string(), + boolean_prop("Include the callers section", true), + ); + properties.insert( + "includeTests".to_string(), + boolean_prop("Include the related-tests section", true), + ); + properties.insert( + "includeMemories".to_string(), + boolean_prop("Include the memories section", true), + ); + properties.insert( + "includeRecentChanges".to_string(), + boolean_prop("Include the recent git changes section", true), + ); + properties.insert( + "maxCallers".to_string(), + number_prop("Maximum number of callers to include (default: 10)", Some(10.0)), + ); + properties.insert( + "maxTests".to_string(), + number_prop("Maximum number of related tests to include (default: 5)", Some(5.0)), + ); Tool { name: "codegraph_get_edit_context".to_string(), - description: Some("Assembles everything needed to edit code at a specific location in a single call. USE WHEN: you are about to modify, refactor, or fix code and need full context before making changes. PREFER THIS over codegraph_get_ai_context when you are about to write or modify code — it includes callers (impact), tests (what to update), and git history (recent context) that get_ai_context does not. Use get_ai_context instead when you only need to understand or explain code. Returns 5 sections: (1) symbol — full source code of the function/method at the given line, (2) callers — functions that call this symbol (to assess impact of changes), (3) tests — related test functions (to know what to update/run), (4) memories — relevant debug notes, architectural decisions, and known issues, (5) recentChanges — recent git commits that touched this file. EXAMPLE: Before modifying a function's signature, call this to see all callers that would break, tests that need updating, and whether someone recently changed this code. Token budget controls total context size with priority: symbol > callers > tests > memories > git history. Requires uri and line parameters.".to_string()), + description: Some("Assembles everything needed to edit code at a specific location in a single call. USE WHEN: you are about to modify, refactor, or fix code and need full context before making changes. PREFER THIS over codegraph_get_ai_context when you are about to write or modify code — it includes callers (impact), tests (what to update), and git history (recent context) that get_ai_context does not. Use get_ai_context instead when you only need to understand or explain code. Returns up to 5 sections, each independently toggleable via includeSymbol/includeCallers/includeTests/includeMemories/includeRecentChanges (skipping a section skips its underlying queries too, not just its output — the cheapest way to shrink this call): (1) symbol — source code of the function/method at the given line, (2) callers — functions that call this symbol (to assess impact of changes, capped via maxCallers), (3) tests — related test functions (to know what to update/run, capped via maxTests), (4) memories — relevant debug notes, architectural decisions, and known issues, (5) recentChanges — recent git commits that touched this file. EXAMPLE: Before modifying a function's signature, call this to see all callers that would break, tests that need updating, and whether someone recently changed this code. Token budget controls total context size with priority: symbol > callers > tests > memories > git history. Requires uri and line parameters.".to_string()), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: Some(properties), @@ -508,6 +544,34 @@ fn get_symbol_info_tool() -> Tool { } } +fn probe_symbol_tool() -> Tool { + let mut properties = HashMap::new(); + properties.insert( + "uri".to_string(), + string_prop( + "The file URI containing the symbol (e.g. file:///Users/me/project/src/main.rs)", + ), + ); + properties.insert( + "line".to_string(), + number_prop("Line number of the symbol (0-indexed)", None), + ); + properties.insert( + "nodeId".to_string(), + string_prop("Internal node ID from symbol_search. Alternative to uri+line."), + ); + + Tool { + name: "codegraph_probe_symbol".to_string(), + description: Some("Cheapest possible symbol lookup — confirms what you'd resolve to before paying for a heavier call. USE WHEN: you're about to call get_symbol_info/get_detailed_symbol/get_edit_context and want to check first whether uri+line lands on the symbol you expect, or whether it'll fall back to a nearby one. Returns only: name, type, node_id, uri, line_start, line_end, used_fallback, match_confidence (\"exact\" | \"fallback\") — no source, no callers/callees, no graph traversal. Requires uri+line or nodeId.".to_string()), + input_schema: ToolInputSchema { + schema_type: "object".to_string(), + properties: Some(properties), + required: None, + }, + } +} + fn analyze_complexity_tool() -> Tool { let mut properties = HashMap::new(); properties.insert( @@ -900,10 +964,19 @@ fn get_detailed_symbol_tool() -> Tool { "includeCallees".to_string(), boolean_prop("Include list of callees", true), ); + properties.insert( + "compact".to_string(), + boolean_prop( + "Cap callers/callees at 5 entries each (with a *_truncated count) — \ + use for symbols with large caller/callee lists to avoid paying for \ + hundreds of entries you won't read", + false, + ), + ); Tool { name: "codegraph_get_detailed_symbol".to_string(), - description: Some("Gets comprehensive symbol details including source code and relationships. USE WHEN: you need full context about a symbol — source code, callers, callees, complexity, and metadata together. MORE COMPLETE than get_symbol_info but heavier. Returns: symbol (name, kind, signature, visibility, uri, line_range, properties), source (full source code string), callers (array), callees (array). Toggle includeSource/includeCallers/includeCallees to control response size. Identify symbol via uri+line or nodeId.".to_string()), + description: Some("Gets comprehensive symbol details including source code and relationships. USE WHEN: you need full context about a symbol — source code, callers, callees, complexity, and metadata together. MORE COMPLETE than get_symbol_info but heavier. Returns: symbol (name, kind, signature, visibility, uri, line_range, properties — no duplicate callers/callees here, see top-level fields), source (full source code string), callers (array), callees (array). Toggle includeSource/includeCallers/includeCallees to control response size, or set compact=true to cap callers/callees at 5 entries each. Identify symbol via uri+line or nodeId.".to_string()), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: Some(properties), @@ -1164,6 +1237,18 @@ fn reindex_workspace_tool() -> Tool { } } +fn index_health_tool() -> Tool { + Tool { + name: "codegraph_index_health".to_string(), + description: Some("Cheap health/freshness check for the current index — no full reindex, safe to call before any risky sequence of queries. USE WHEN: you got surprisingly few/no results and want to know whether the index is stale or empty before assuming the code doesn't exist, or before a multi-step task where you want to confirm the index is trustworthy up front. Returns: namespace, workspace_root, node_count, edge_count, generation, index_generated_at (unix seconds of last persist), workspace_revision_hint (short git HEAD, if a git repo), files_tracked, files_changed_since_index (cheap re-hash of already-tracked files — does not detect brand-new/deleted files, that needs a full reindex), potentially_stale, other_namespaces (other indexed projects sharing this machine's graph database, for cross-namespace comparison), and suggested_next_queries when the index looks unhealthy. No parameters.".to_string()), + input_schema: ToolInputSchema { + schema_type: "object".to_string(), + properties: None, + required: None, + }, + } +} + fn index_files_tool() -> Tool { let mut properties = HashMap::new(); properties.insert( @@ -1728,14 +1813,15 @@ mod tests { #[test] fn test_get_all_tools_count() { let tools = get_all_tools(); - // Analysis: 11, Search: 8, Navigation: 3, Memory: 7, Dead Imports: 1, Ops: 1, Admin: 3, Docs: 7, PR: 1 = 42 community tools + // Analysis: 12 (incl. probe_symbol), Search: 8, Navigation: 3, Memory: 7, + // Dead Imports: 1, Ops: 1, Admin: 4 (incl. index_health), Docs: 7, PR: 1 = 44 community tools // (12 premium tools moved to pro edition: scan_security, analyze_coupling, find_unused_code, // find_duplicates, find_similar, cluster_symbols, compare_symbols, cross_project_search, // mine_git_history, mine_git_history_for_file, search_git_history) assert_eq!( tools.len(), - 42, - "Expected 42 community tools, got {}", + 44, + "Expected 44 community tools, got {}", tools.len() ); } @@ -1825,7 +1911,7 @@ mod tests { .iter() .filter(|t| tool_in_profile(&t.name, ToolProfile::Core)) .collect(); - assert_eq!(kept.len(), 8, "Core profile should expose 8 tools"); + assert_eq!(kept.len(), 9, "Core profile should expose 9 tools"); // Spot-check key inclusions. assert!(kept.iter().any(|t| t.name == "codegraph_symbol_search")); assert!(kept.iter().any(|t| t.name == "codegraph_get_ai_context")); diff --git a/crates/codegraph-server/src/memory.rs b/crates/codegraph-server/src/memory.rs index 10ba1fd..c0a0b37 100644 --- a/crates/codegraph-server/src/memory.rs +++ b/crates/codegraph-server/src/memory.rs @@ -189,6 +189,12 @@ pub struct MemoryManager { embedding_model: codegraph_memory::EmbeddingBackend, } +const MIN_EMBEDDING_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB for model + ort runtime + +pub(crate) fn should_skip_embeddings_for_available_memory(avail: u64) -> bool { + avail != 0 && avail < MIN_EMBEDDING_FREE_BYTES +} + impl MemoryManager { /// Create a new MemoryManager pub fn new(extension_path: Option) -> Self { @@ -284,12 +290,11 @@ impl MemoryManager { let mut sys = sysinfo::System::new(); sys.refresh_memory(); let avail = sys.available_memory(); - const MIN_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB for model + ort runtime tracing::info!( "[MemoryManager::initialize] available memory: {} MB", avail / 1_000_000 ); - if avail < MIN_FREE_BYTES { + if should_skip_embeddings_for_available_memory(avail) { crate::crash_phase::mark("onnx_skipped_lowmem"); tracing::warn!( "[MemoryManager::initialize] only {} MB free — skipping embedding model to avoid OOM; semantic search disabled (graph-only)", @@ -722,4 +727,14 @@ mod tests { // Invalidate it manager.invalidate(&id, "testing").await.unwrap(); } + + #[test] + fn zero_available_memory_is_treated_as_unknown_not_low_memory() { + assert!( + !should_skip_embeddings_for_available_memory(0), + "a zero reading from sysinfo means unknown/broken probe, not OOM" + ); + assert!(should_skip_embeddings_for_available_memory(1_499_999_999)); + assert!(!should_skip_embeddings_for_available_memory(1_500_000_000)); + } }