Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ members = [
]

[workspace.package]
version = "0.19.0"
version = "0.19.1"
edition = "2021"
license = "Apache-2.0"
repository = "https://github.com/codegraph-ai/codegraph"
Expand Down
3 changes: 2 additions & 1 deletion crates/codegraph-rust/src/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ pub fn ir_to_graph(
.with("line_end", func.line_end as i64)
.with("is_async", func.is_async)
.with("is_static", func.is_static)
.with("is_abstract", func.is_abstract);
.with("is_abstract", func.is_abstract)
.with("is_test", func.is_test);

// Add complexity metrics if available
if let Some(ref complexity) = func.complexity {
Expand Down
27 changes: 27 additions & 0 deletions crates/codegraph-rust/src/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,33 @@ fn test_something() {}
assert!(visitor.functions[0].is_test);
}

#[test]
fn test_visitor_test_fn_inside_cfg_test_mod() {
// The idiomatic Rust unit-test shape: a #[test] fn with a descriptive
// (non-`test_`) name inside `#[cfg(test)] mod tests`. This is what the
// PR-review coverage analysis was missing.
let source = r#"
fn weighted_mean_l2() {}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn weighted_mean_l2_math() {
weighted_mean_l2();
}
}
"#;
let visitor = parse_and_visit(source);
let t = visitor
.functions
.iter()
.find(|f| f.name == "weighted_mean_l2_math")
.expect("nested test fn should be visited");
assert!(t.is_test, "#[test] fn inside `mod tests` must set is_test");
}

#[test]
fn test_visitor_visibility_modifiers() {
let source = r#"
Expand Down
8 changes: 8 additions & 0 deletions crates/codegraph-server/src/domain/node_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,11 @@ pub(crate) fn is_public(node: &Node) -> bool {
.or_else(|| node.properties.get_bool("exported"))
.unwrap_or_else(|| matches!(visibility(node), "public" | "pub"))
}

/// Whether the node is a test function, as recorded at index time from the
/// language's test marker (`#[test]`/`#[cfg(test)]`, `@Test`, etc.). This is
/// the structural signal; callers should prefer it over name heuristics, which
/// miss idiomatic test names (Rust `#[cfg(test)] mod tests { fn descriptive() }`).
pub(crate) fn is_test(node: &Node) -> bool {
node.properties.get_bool("is_test").unwrap_or(false)
}
70 changes: 55 additions & 15 deletions crates/codegraph-server/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4046,6 +4046,10 @@ impl McpServer {
let mut all_tests = Vec::new();
let mut untested_functions = Vec::new();
let mut total_direct = 0usize;
// Callers of signature-changed functions — the ones that can
// actually break. Body-only changes don't break callers, so
// they shouldn't drive the risk level or blast radius.
let mut breaking_callers = 0usize;
let mut all_affected_files = std::collections::HashSet::new();
let mut func_details = Vec::new();

Expand Down Expand Up @@ -4104,12 +4108,25 @@ impl McpServer {
if let Ok(caller) = graph.get_node(caller_id) {
let cname = crate::domain::node_props::name(caller);
let cfile = caller.properties.get_string("path").unwrap_or("");
let is_test = cname.to_lowercase().starts_with("test_")
// Prefer the structural is_test marker recorded at index time
// (#[test]/#[cfg(test)], @Test, …); fall back to name/path
// heuristics only for languages that don't populate it. The
// heuristics alone miss idiomatic Rust tests with descriptive
// names inside `#[cfg(test)] mod tests`.
let is_test = crate::domain::node_props::is_test(caller)
|| cname.to_lowercase().starts_with("test_")
|| cname.to_lowercase().contains("_test")
|| cfile.contains("/tests/")
|| cfile.contains("/test_");

if is_test {
// Callers under examples/ (and doctests) exercise the
// function at runtime — count them as coverage, not as
// breakable production callers. This is what covers code
// driven only by eval/example harnesses.
let is_exercising = !is_test
&& (cfile.contains("/examples/")
|| cfile.contains("/benches/"));

if is_test || is_exercising {
has_test_caller = true;
all_tests.push(serde_json::json!({
"test": cname, "file": cfile, "covers": func_name,
Expand All @@ -4128,11 +4145,18 @@ impl McpServer {
}
}
total_direct += caller_count as usize;
if change_type == "signature_changed" {
breaking_callers += caller_count as usize;
}

// #87: Test gap — function has no test callers.
// Skip functions that ARE tests (they don't need
// their own coverage) and trivial getters/setters.
let fn_is_test = func_name.to_lowercase().starts_with("test_")
let fn_is_test = graph
.get_node(*node_id)
.ok()
.is_some_and(|n| crate::domain::node_props::is_test(n))
|| func_name.to_lowercase().starts_with("test_")
|| func_name.to_lowercase().contains("_test")
|| changed_rel[idx].contains("/tests/")
|| changed_rel[idx].contains("_test.");
Expand Down Expand Up @@ -4282,10 +4306,29 @@ impl McpServer {

drop(graph);

// Risk level
let risk_level = if total_direct > 20 || untested_functions.len() > 5 {
// Functions actually touched by this PR (denominator for ratios).
let total_functions: u64 = file_impacts
.iter()
.map(|f| f["functions_changed"].as_u64().unwrap_or(0))
.sum();

// Risk is driven by what can actually break — callers of
// signature-changed functions — and by the share of touched
// functions left untested, NOT by the raw caller count (which
// body-only changes inflate, e.g. a widely-called helper whose
// body changed but signature didn't).
let untested_ratio = if total_functions > 0 {
untested_functions.len() as f64 / total_functions as f64
} else {
0.0
};
let risk_level = if breaking_callers > 25
|| (untested_functions.len() > 5 && untested_ratio > 0.5)
{
"high"
} else if total_direct > 5 || untested_functions.len() > 2 {
} else if breaking_callers > 8
|| (untested_functions.len() > 2 && untested_ratio > 0.25)
{
"medium"
} else {
"low"
Expand All @@ -4305,28 +4348,24 @@ impl McpServer {
commit_prefix, primary_module
);

let total_functions: u64 = file_impacts
.iter()
.map(|f| f["functions_changed"].as_u64().unwrap_or(0))
.sum();

let mut result = serde_json::json!({
"base_branch": base,
"changed_files": changed_rel.len(),
"lines_added": lines_added,
"lines_removed": lines_removed,
"functions_touched": total_functions,
"direct_callers": total_direct,
"breaking_callers": breaking_callers,
"related_tests": unique_tests.len(),
"untested_functions": untested_functions.len(),
"affected_modules": affected_modules,
"risk_level": risk_level,
"commit_hint": commit_hint,
"files": file_impacts,
"message": format!(
"PR changes {} files (+{}/-{}, {} functions). {} direct callers, {} tests, {} untested. Risk: {}.",
"PR changes {} files (+{}/-{}, {} functions). {} direct callers ({} breaking), {} tests, {} untested. Risk: {}.",
changed_rel.len(), lines_added, lines_removed, total_functions,
total_direct, unique_tests.len(), untested_functions.len(), risk_level,
total_direct, breaking_callers, unique_tests.len(), untested_functions.len(), risk_level,
),
});

Expand Down Expand Up @@ -4385,9 +4424,10 @@ impl McpServer {

if total_direct > 0 {
md.push_str(&format!(
"### Blast radius\n{} direct caller{} affected",
"### Blast radius\n{} direct caller{} affected ({} breaking)",
total_direct,
if total_direct == 1 { "" } else { "s" },
breaking_callers,
));
if !affected_modules.is_empty() {
let mods: Vec<String> = affected_modules
Expand Down
2 changes: 1 addition & 1 deletion mcp-package/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@astudioplus/codegraph-mcp",
"version": "0.19.0",
"version": "0.19.1",
"mcpName": "io.github.codegraph-ai/codegraph",
"description": "CodeGraph MCP server — cross-language code intelligence with 42 tools, 38 languages",
"author": "Andrey Vasilevsky <anvanster@gmail.com>",
Expand Down
4 changes: 2 additions & 2 deletions mcp-package/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/codegraph-ai/CodeGraph",
"source": "github"
},
"version": "0.19.0",
"version": "0.19.1",
"packages": [
{
"registryType": "npm",
"identifier": "@astudioplus/codegraph-mcp",
"version": "0.19.0",
"version": "0.19.1",
"transport": {
"type": "stdio"
},
Expand Down
2 changes: 1 addition & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "codegraph",
"displayName": "CodeGraph",
"description": "Cross-language code intelligence powered by graph analysis",
"version": "0.19.0",
"version": "0.19.1",
"publisher": "aStudioPlus",
"author": "Andrey Vasilevsky <anvanster@gmail.com>",
"license": "Apache-2.0",
Expand Down
Loading