Skip to content
Open
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 22 additions & 2 deletions crates/codegraph-python/src/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
67 changes: 64 additions & 3 deletions crates/codegraph-python/src/parser_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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};
Expand Down
2 changes: 2 additions & 0 deletions crates/codegraph-server/src/domain/call_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub(crate) struct CallGraphResult {
pub used_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_message: Option<String>,
pub match_confidence: &'static str,
}

// ============================================================
Expand Down Expand Up @@ -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),
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/codegraph-server/src/domain/callers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub(crate) struct CallersResult {
pub used_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_message: Option<String>,
pub match_confidence: &'static str,
}

/// Result of `get_callees`.
Expand All @@ -49,6 +50,7 @@ pub(crate) struct CalleesResult {
pub used_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_message: Option<String>,
pub match_confidence: &'static str,
}

// ============================================================
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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),
}
}

Expand Down
Loading