@@ -4046,6 +4046,10 @@ impl McpServer {
40464046 let mut all_tests = Vec :: new ( ) ;
40474047 let mut untested_functions = Vec :: new ( ) ;
40484048 let mut total_direct = 0usize ;
4049+ // Callers of signature-changed functions — the ones that can
4050+ // actually break. Body-only changes don't break callers, so
4051+ // they shouldn't drive the risk level or blast radius.
4052+ let mut breaking_callers = 0usize ;
40494053 let mut all_affected_files = std:: collections:: HashSet :: new ( ) ;
40504054 let mut func_details = Vec :: new ( ) ;
40514055
@@ -4104,12 +4108,25 @@ impl McpServer {
41044108 if let Ok ( caller) = graph. get_node ( caller_id) {
41054109 let cname = crate :: domain:: node_props:: name ( caller) ;
41064110 let cfile = caller. properties . get_string ( "path" ) . unwrap_or ( "" ) ;
4107- let is_test = cname. to_lowercase ( ) . starts_with ( "test_" )
4111+ // Prefer the structural is_test marker recorded at index time
4112+ // (#[test]/#[cfg(test)], @Test, …); fall back to name/path
4113+ // heuristics only for languages that don't populate it. The
4114+ // heuristics alone miss idiomatic Rust tests with descriptive
4115+ // names inside `#[cfg(test)] mod tests`.
4116+ let is_test = crate :: domain:: node_props:: is_test ( caller)
4117+ || cname. to_lowercase ( ) . starts_with ( "test_" )
41084118 || cname. to_lowercase ( ) . contains ( "_test" )
41094119 || cfile. contains ( "/tests/" )
41104120 || cfile. contains ( "/test_" ) ;
4111-
4112- if is_test {
4121+ // Callers under examples/ (and doctests) exercise the
4122+ // function at runtime — count them as coverage, not as
4123+ // breakable production callers. This is what covers code
4124+ // driven only by eval/example harnesses.
4125+ let is_exercising = !is_test
4126+ && ( cfile. contains ( "/examples/" )
4127+ || cfile. contains ( "/benches/" ) ) ;
4128+
4129+ if is_test || is_exercising {
41134130 has_test_caller = true ;
41144131 all_tests. push ( serde_json:: json!( {
41154132 "test" : cname, "file" : cfile, "covers" : func_name,
@@ -4128,11 +4145,18 @@ impl McpServer {
41284145 }
41294146 }
41304147 total_direct += caller_count as usize ;
4148+ if change_type == "signature_changed" {
4149+ breaking_callers += caller_count as usize ;
4150+ }
41314151
41324152 // #87: Test gap — function has no test callers.
41334153 // Skip functions that ARE tests (they don't need
41344154 // their own coverage) and trivial getters/setters.
4135- let fn_is_test = func_name. to_lowercase ( ) . starts_with ( "test_" )
4155+ let fn_is_test = graph
4156+ . get_node ( * node_id)
4157+ . ok ( )
4158+ . is_some_and ( |n| crate :: domain:: node_props:: is_test ( n) )
4159+ || func_name. to_lowercase ( ) . starts_with ( "test_" )
41364160 || func_name. to_lowercase ( ) . contains ( "_test" )
41374161 || changed_rel[ idx] . contains ( "/tests/" )
41384162 || changed_rel[ idx] . contains ( "_test." ) ;
@@ -4282,10 +4306,29 @@ impl McpServer {
42824306
42834307 drop ( graph) ;
42844308
4285- // Risk level
4286- let risk_level = if total_direct > 20 || untested_functions. len ( ) > 5 {
4309+ // Functions actually touched by this PR (denominator for ratios).
4310+ let total_functions: u64 = file_impacts
4311+ . iter ( )
4312+ . map ( |f| f[ "functions_changed" ] . as_u64 ( ) . unwrap_or ( 0 ) )
4313+ . sum ( ) ;
4314+
4315+ // Risk is driven by what can actually break — callers of
4316+ // signature-changed functions — and by the share of touched
4317+ // functions left untested, NOT by the raw caller count (which
4318+ // body-only changes inflate, e.g. a widely-called helper whose
4319+ // body changed but signature didn't).
4320+ let untested_ratio = if total_functions > 0 {
4321+ untested_functions. len ( ) as f64 / total_functions as f64
4322+ } else {
4323+ 0.0
4324+ } ;
4325+ let risk_level = if breaking_callers > 25
4326+ || ( untested_functions. len ( ) > 5 && untested_ratio > 0.5 )
4327+ {
42874328 "high"
4288- } else if total_direct > 5 || untested_functions. len ( ) > 2 {
4329+ } else if breaking_callers > 8
4330+ || ( untested_functions. len ( ) > 2 && untested_ratio > 0.25 )
4331+ {
42894332 "medium"
42904333 } else {
42914334 "low"
@@ -4305,28 +4348,24 @@ impl McpServer {
43054348 commit_prefix, primary_module
43064349 ) ;
43074350
4308- let total_functions: u64 = file_impacts
4309- . iter ( )
4310- . map ( |f| f[ "functions_changed" ] . as_u64 ( ) . unwrap_or ( 0 ) )
4311- . sum ( ) ;
4312-
43134351 let mut result = serde_json:: json!( {
43144352 "base_branch" : base,
43154353 "changed_files" : changed_rel. len( ) ,
43164354 "lines_added" : lines_added,
43174355 "lines_removed" : lines_removed,
43184356 "functions_touched" : total_functions,
43194357 "direct_callers" : total_direct,
4358+ "breaking_callers" : breaking_callers,
43204359 "related_tests" : unique_tests. len( ) ,
43214360 "untested_functions" : untested_functions. len( ) ,
43224361 "affected_modules" : affected_modules,
43234362 "risk_level" : risk_level,
43244363 "commit_hint" : commit_hint,
43254364 "files" : file_impacts,
43264365 "message" : format!(
4327- "PR changes {} files (+{}/-{}, {} functions). {} direct callers, {} tests, {} untested. Risk: {}." ,
4366+ "PR changes {} files (+{}/-{}, {} functions). {} direct callers ({} breaking) , {} tests, {} untested. Risk: {}." ,
43284367 changed_rel. len( ) , lines_added, lines_removed, total_functions,
4329- total_direct, unique_tests. len( ) , untested_functions. len( ) , risk_level,
4368+ total_direct, breaking_callers , unique_tests. len( ) , untested_functions. len( ) , risk_level,
43304369 ) ,
43314370 } ) ;
43324371
@@ -4385,9 +4424,10 @@ impl McpServer {
43854424
43864425 if total_direct > 0 {
43874426 md. push_str ( & format ! (
4388- "### Blast radius\n {} direct caller{} affected" ,
4427+ "### Blast radius\n {} direct caller{} affected ({} breaking) " ,
43894428 total_direct,
43904429 if total_direct == 1 { "" } else { "s" } ,
4430+ breaking_callers,
43914431 ) ) ;
43924432 if !affected_modules. is_empty ( ) {
43934433 let mods: Vec < String > = affected_modules
0 commit comments