@@ -8,10 +8,31 @@ use crate::config::HotspotsConfig;
88#[ derive( Debug , Clone ) ]
99pub struct ChurnInfo {
1010 pub commit_count : i64 ,
11+ /// Commits whose author looks like a bot account (see `is_bot_author`) —
12+ /// counted separately from `commit_count` so `churn_source` can tell
13+ /// human-driven churn from CI/dependency-bump noise.
14+ pub bot_commit_count : i64 ,
1115 pub authors : HashSet < String > ,
1216 pub last_changed : Option < String > ,
1317}
1418
19+ impl ChurnInfo {
20+ /// "unknown" with no churn data at all (e.g. git unavailable),
21+ /// "human" when no commit came from a bot account, "bot_dominated" when
22+ /// every commit did, "mixed" otherwise.
23+ pub fn churn_source ( & self ) -> & ' static str {
24+ if self . commit_count == 0 {
25+ "unknown"
26+ } else if self . bot_commit_count == 0 {
27+ "human"
28+ } else if self . bot_commit_count >= self . commit_count {
29+ "bot_dominated"
30+ } else {
31+ "mixed"
32+ }
33+ }
34+ }
35+
1536#[ derive( Debug , Clone ) ]
1637pub struct ComplexityInfo {
1738 pub symbol_count : i64 ,
@@ -41,6 +62,13 @@ pub struct HotspotEntry {
4162 pub language : String ,
4263 pub churn : ChurnInfo ,
4364 pub complexity : ComplexityInfo ,
65+ /// Churn share (0-1) of `hotspot_score`'s numerator, normalized against
66+ /// the busiest candidate file this run — 0.0 when git is unavailable
67+ /// (no churn signal at all, not "no churn").
68+ pub norm_churn : f64 ,
69+ /// Complexity share (0-1) of `hotspot_score`'s numerator, normalized
70+ /// against the most complex candidate file this run.
71+ pub norm_compl : f64 ,
4472 pub hotspot_score : f64 ,
4573 pub risk_level : String ,
4674 pub top_symbols : Option < Vec < HotspotSymbol > > ,
@@ -87,6 +115,7 @@ pub fn compute_hotspots(
87115 path. clone ( ) ,
88116 ChurnInfo {
89117 commit_count : 0 ,
118+ bot_commit_count : 0 ,
90119 authors : HashSet :: new ( ) ,
91120 last_changed : None ,
92121 } ,
@@ -141,10 +170,13 @@ pub fn compute_hotspots(
141170 . iter ( )
142171 . filter_map ( |( path, churn) | {
143172 let cm = complexity_map. get ( path) ?;
173+ // Computed unconditionally (not just when `git_available`) so
174+ // callers can see the churn share even when it didn't factor
175+ // into `hotspot_score` — 0.0 uniformly when git is unavailable,
176+ // since every candidate's `commit_count` is 0 in that branch.
177+ let norm_churn = churn_scores. get ( path. as_str ( ) ) . copied ( ) . unwrap_or ( 0.0 ) / max_churn;
144178 let norm_compl = compl_scores. get ( path. as_str ( ) ) . copied ( ) . unwrap_or ( 0.0 ) / max_compl;
145179 let score = if git_available {
146- let norm_churn =
147- churn_scores. get ( path. as_str ( ) ) . copied ( ) . unwrap_or ( 0.0 ) / max_churn;
148180 norm_churn * norm_compl
149181 } else {
150182 norm_compl
@@ -165,6 +197,8 @@ pub fn compute_hotspots(
165197 language : cm. language . clone ( ) ,
166198 churn : churn. clone ( ) ,
167199 complexity : cm. clone ( ) ,
200+ norm_churn : ( norm_churn * 10000.0 ) . round ( ) / 10000.0 ,
201+ norm_compl : ( norm_compl * 10000.0 ) . round ( ) / 10000.0 ,
168202 hotspot_score : ( score * 10000.0 ) . round ( ) / 10000.0 ,
169203 risk_level : risk_level. to_string ( ) ,
170204 top_symbols : None ,
@@ -283,19 +317,31 @@ fn collect_git_churn(project_root: &Path, since: &str) -> (HashMap<String, Churn
283317 for path in & commit. files {
284318 let entry = churn_map. entry ( path. clone ( ) ) . or_insert_with ( || ChurnInfo {
285319 commit_count : 0 ,
320+ bot_commit_count : 0 ,
286321 authors : HashSet :: new ( ) ,
287322 // H-1 fix: last_changed is Option<String>, None instead of ""
288323 last_changed : commit. date . clone ( ) ,
289324 } ) ;
290325 entry. commit_count += 1 ;
291326 if let Some ( ref author) = commit. author {
292327 entry. authors . insert ( author. clone ( ) ) ;
328+ if is_bot_author ( author) {
329+ entry. bot_commit_count += 1 ;
330+ }
293331 }
294332 }
295333 }
296334 ( churn_map, true )
297335}
298336
337+ /// GitHub's bot accounts (dependabot[bot], renovate[bot], github-actions[bot],
338+ /// etc.) all carry `[bot]` in their noreply author email (`%ae`), which is
339+ /// what `commits_with_files` captures — this substring check covers them
340+ /// without hardcoding a specific bot allowlist.
341+ fn is_bot_author ( author : & str ) -> bool {
342+ author. contains ( "[bot]" )
343+ }
344+
299345fn collect_complexity ( conn : & Connection ) -> HashMap < String , ComplexityInfo > {
300346 // is_test = 0: test functions structurally call production code, so they
301347 // almost always end up with coreness > 0 despite never being called
@@ -512,6 +558,100 @@ mod tests {
512558 assert_eq ! ( output. hotspots[ 0 ] . churn. commit_count, 2 ) ;
513559 }
514560
561+ #[ test]
562+ fn test_churn_source_classifies_human_bot_mixed_and_unknown ( ) {
563+ let unknown = ChurnInfo {
564+ commit_count : 0 ,
565+ bot_commit_count : 0 ,
566+ authors : HashSet :: new ( ) ,
567+ last_changed : None ,
568+ } ;
569+ assert_eq ! ( unknown. churn_source( ) , "unknown" ) ;
570+
571+ let human = ChurnInfo {
572+ commit_count : 3 ,
573+ bot_commit_count : 0 ,
574+ authors : HashSet :: new ( ) ,
575+ last_changed : None ,
576+ } ;
577+ assert_eq ! ( human. churn_source( ) , "human" ) ;
578+
579+ let bot_dominated = ChurnInfo {
580+ commit_count : 3 ,
581+ bot_commit_count : 3 ,
582+ authors : HashSet :: new ( ) ,
583+ last_changed : None ,
584+ } ;
585+ assert_eq ! ( bot_dominated. churn_source( ) , "bot_dominated" ) ;
586+
587+ let mixed = ChurnInfo {
588+ commit_count : 4 ,
589+ bot_commit_count : 1 ,
590+ authors : HashSet :: new ( ) ,
591+ last_changed : None ,
592+ } ;
593+ assert_eq ! ( mixed. churn_source( ) , "mixed" ) ;
594+ }
595+
596+ #[ test]
597+ fn test_is_bot_author_matches_bot_suffix ( ) {
598+ assert ! ( is_bot_author(
599+ "49699333+dependabot[bot]@users.noreply.github.com"
600+ ) ) ;
601+ assert ! ( is_bot_author(
602+ "29139614+renovate[bot]@users.noreply.github.com"
603+ ) ) ;
604+ assert ! ( !is_bot_author( "jane@example.com" ) ) ;
605+ }
606+
607+ #[ test]
608+ fn test_collect_git_churn_tallies_bot_commits_separately ( ) {
609+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
610+ run_git ( dir. path ( ) , & [ "init" , "-q" ] ) ;
611+ run_git ( dir. path ( ) , & [ "config" , "user.email" , "human@example.com" ] ) ;
612+ run_git ( dir. path ( ) , & [ "config" , "user.name" , "Human" ] ) ;
613+ std:: fs:: write ( dir. path ( ) . join ( "f.py" ) , "x = 1\n " ) . unwrap ( ) ;
614+ run_git ( dir. path ( ) , & [ "add" , "f.py" ] ) ;
615+ run_git ( dir. path ( ) , & [ "commit" , "-q" , "-m" , "init" ] ) ;
616+
617+ run_git (
618+ dir. path ( ) ,
619+ & [
620+ "config" ,
621+ "user.email" ,
622+ "49699333+dependabot[bot]@users.noreply.github.com" ,
623+ ] ,
624+ ) ;
625+ run_git ( dir. path ( ) , & [ "config" , "user.name" , "dependabot[bot]" ] ) ;
626+ std:: fs:: write ( dir. path ( ) . join ( "f.py" ) , "x = 2\n " ) . unwrap ( ) ;
627+ run_git ( dir. path ( ) , & [ "commit" , "-q" , "-am" , "bump" ] ) ;
628+
629+ let ( churn_map, git_available) = collect_git_churn ( dir. path ( ) , "1 year" ) ;
630+ assert ! ( git_available) ;
631+ let entry = churn_map. get ( "f.py" ) . unwrap ( ) ;
632+ assert_eq ! ( entry. commit_count, 2 ) ;
633+ assert_eq ! ( entry. bot_commit_count, 1 ) ;
634+ assert_eq ! ( entry. churn_source( ) , "mixed" ) ;
635+ }
636+
637+ #[ test]
638+ fn test_norm_compl_and_norm_churn_are_exposed_without_git ( ) {
639+ let conn = setup_db ( ) ;
640+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
641+ insert_symbol ( & conn, "a.func1" , "/a.py" , 0 , false , 0 ) ;
642+ insert_symbol ( & conn, "b.func1" , "/b.py" , 10 , true , 5 ) ;
643+ insert_symbol ( & conn, "b.func2" , "/b.py" , 2 , false , 1 ) ;
644+
645+ let config = HotspotsConfig :: default ( ) ;
646+ let output = compute_hotspots ( dir. path ( ) , & conn, & config, 10 , "6 months ago" , 2 , false ) ;
647+ assert ! ( !output. git_available) ;
648+ // No churn signal at all when git is unavailable.
649+ assert ! ( output. hotspots. iter( ) . all( |h| h. norm_churn == 0.0 ) ) ;
650+ // b.py is the most complex candidate, so its complexity share is 1.0.
651+ let b = output. hotspots . iter ( ) . find ( |h| h. path == "/b.py" ) . unwrap ( ) ;
652+ assert_eq ! ( b. norm_compl, 1.0 ) ;
653+ }
654+
515655 /// Regression: `compute_absolute_hotspot_risk` must NOT saturate to
516656 /// ~1.0 just because one file is *relatively* the busiest in a small,
517657 /// healthy repo — unlike `compute_hotspots`' `hotspot_score` (min-max
0 commit comments