1#![allow(
2 clippy::print_stderr,
3 reason = "human stderr notes (no-git, bot patterns, CODEOWNERS) preserved verbatim from the CLI health path"
4)]
5
6use fallow_output::{FileHealthScore, HotspotEntry, HotspotSummary};
7
8use super::HealthOptions;
9use super::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
10
11fn is_test_path(relative: &std::path::Path) -> bool {
15 let s = relative.to_string_lossy().replace('\\', "/");
16 s.contains("/__tests__/")
17 || s.contains("/__mocks__/")
18 || s.contains("/test/")
19 || s.contains("/tests/")
20 || s.contains(".test.")
21 || s.contains(".spec.")
22}
23
24pub struct ChurnFetchResult {
26 pub result: crate::churn::ChurnResult,
27 pub since: crate::churn::SinceDuration,
28 pub cache_hit: bool,
29 pub git_log_ms: f64,
30}
31
32pub struct TargetChurnOptions<'a> {
37 pub root: &'a std::path::Path,
39 pub target: &'a std::path::Path,
41 pub cache_dir: std::path::PathBuf,
43 pub no_cache: bool,
45 pub since: Option<&'a str>,
48 pub min_commits: Option<u32>,
50}
51
52#[derive(Debug)]
54pub struct TargetChurnEvidence {
55 pub file: crate::churn::FileChurn,
57 pub since: crate::churn::SinceDuration,
59 pub min_commits: u32,
61 pub shallow_clone: bool,
64}
65
66#[derive(Debug)]
68pub enum TargetChurnOutcome {
69 Found(TargetChurnEvidence),
71 NoQualifyingChurn {
73 observed_commits: Option<u32>,
75 since: crate::churn::SinceDuration,
77 min_commits: u32,
79 shallow_clone: bool,
82 },
83 Unavailable {
85 message: String,
87 },
88}
89
90pub fn analyze_target_churn(
96 options: &TargetChurnOptions<'_>,
97) -> Result<TargetChurnOutcome, String> {
98 analyze_target_churn_with(
99 options,
100 crate::churn::is_git_repo,
101 crate::churn::analyze_churn_cached,
102 )
103}
104
105fn analyze_target_churn_with<GitAvailable, Analyze>(
106 options: &TargetChurnOptions<'_>,
107 git_available: GitAvailable,
108 analyze: Analyze,
109) -> Result<TargetChurnOutcome, String>
110where
111 GitAvailable: FnOnce(&std::path::Path) -> bool,
112 Analyze: FnOnce(
113 &std::path::Path,
114 &crate::churn::SinceDuration,
115 &std::path::Path,
116 bool,
117 ) -> Option<(crate::churn::ChurnResult, bool)>,
118{
119 if !git_available(options.root) {
120 return Ok(TargetChurnOutcome::Unavailable {
121 message: "git repository unavailable at project root".to_string(),
122 });
123 }
124
125 let since = crate::churn::parse_since(options.since.unwrap_or("6m"))?;
126 let min_commits = options.min_commits.unwrap_or(3);
127 let Some((result, _cache_hit)) =
128 analyze(options.root, &since, &options.cache_dir, options.no_cache)
129 else {
130 return Err("git churn analysis failed".to_string());
131 };
132 let shallow_clone = result.shallow_clone;
133 let target = options.root.join(options.target);
134 let file = result.files.get(&target).cloned();
135
136 let observed_commits = file.as_ref().map(|file| file.commits);
137 if let Some(file) = file
138 && file.commits >= min_commits
139 {
140 return Ok(TargetChurnOutcome::Found(TargetChurnEvidence {
141 file,
142 since,
143 min_commits,
144 shallow_clone,
145 }));
146 }
147
148 Ok(TargetChurnOutcome::NoQualifyingChurn {
149 observed_commits,
150 since,
151 min_commits,
152 shallow_clone,
153 })
154}
155
156pub(super) fn fetch_churn_data(
164 opts: &HealthOptions<'_>,
165 cache_dir: &std::path::Path,
166) -> Option<ChurnFetchResult> {
167 if let Some(churn_file) = opts.churn_file {
173 let resolved =
174 crate::health::scoring::resolve_relative_to_root(churn_file, Some(opts.root));
175 let t = std::time::Instant::now();
176 let result = match crate::churn::analyze_churn_from_file(&resolved, opts.root) {
177 Ok(r) => r,
178 Err(e) => {
179 tracing::warn!("churn file became unreadable after validation: {e}");
186 return None;
187 }
188 };
189 return Some(ChurnFetchResult {
190 result,
191 since: imported_since(),
192 cache_hit: false,
193 git_log_ms: t.elapsed().as_secs_f64() * 1000.0,
194 });
195 }
196
197 if !crate::churn::is_git_repo(opts.root) {
198 if !opts.quiet {
199 eprintln!("note: hotspot analysis skipped: no git repository found at project root");
200 }
201 return None;
202 }
203
204 let since_input = opts.since.unwrap_or("6m");
205 if let Err(e) = crate::validate::validate_no_control_chars(since_input, "--since") {
206 tracing::warn!("hotspot analysis skipped: {e}");
211 return None;
212 }
213 let since = match crate::churn::parse_since(since_input) {
214 Ok(s) => s,
215 Err(e) => {
216 tracing::warn!("hotspot analysis skipped: invalid --since: {e}");
217 return None;
218 }
219 };
220
221 let t = std::time::Instant::now();
222 let (churn_result, cache_hit) =
223 crate::churn::analyze_churn_cached(opts.root, &since, cache_dir, opts.no_cache)?;
224 let git_log_ms = t.elapsed().as_secs_f64() * 1000.0;
225
226 Some(ChurnFetchResult {
227 result: churn_result,
228 since,
229 cache_hit,
230 git_log_ms,
231 })
232}
233
234fn imported_since() -> crate::churn::SinceDuration {
238 crate::churn::SinceDuration {
239 git_after: String::new(),
240 display: "imported churn".to_string(),
241 }
242}
243
244fn compute_normalization_maxima(
248 file_scores: &[FileHealthScore],
249 churn_files: &rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
250 min_commits: u32,
251) -> (f64, f64) {
252 let mut max_weighted: f64 = 0.0;
253 let mut max_density: f64 = 0.0;
254 for score in file_scores {
255 if let Some(churn) = churn_files.get(&score.path)
256 && churn.commits >= min_commits
257 {
258 max_weighted = max_weighted.max(churn.weighted_commits);
259 max_density = max_density.max(score.complexity_density);
260 }
261 }
262 (max_weighted, max_density)
263}
264
265fn is_excluded_from_hotspots(
268 path: &std::path::Path,
269 root: &std::path::Path,
270 ignore_set: &globset::GlobSet,
271 ws_roots: Option<&[std::path::PathBuf]>,
272) -> bool {
273 if let Some(ws) = ws_roots
274 && !ws.iter().any(|r| path.starts_with(r))
275 {
276 return true;
277 }
278 if !ignore_set.is_empty() {
279 let relative = path.strip_prefix(root).unwrap_or(path);
280 if ignore_set.is_match(relative) {
281 return true;
282 }
283 }
284 false
285}
286
287fn compute_hotspot_score(
292 weighted_commits: f64,
293 max_weighted: f64,
294 complexity_density: f64,
295 max_density: f64,
296) -> f64 {
297 let norm_churn = if max_weighted > 0.0 {
298 weighted_commits / max_weighted
299 } else {
300 0.0
301 };
302 let norm_complexity = if max_density > 0.0 {
303 complexity_density / max_density
304 } else {
305 0.0
306 };
307 (norm_churn * norm_complexity * 100.0 * 10.0).round() / 10.0
308}
309
310pub(super) struct HotspotComputationInput<'a> {
311 pub(super) opts: &'a HealthOptions<'a>,
312 pub(super) config: &'a fallow_config::ResolvedConfig,
313 pub(super) file_scores: &'a [FileHealthScore],
314 pub(super) ignore_set: &'a globset::GlobSet,
315 pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
316 pub(super) churn_fetch: ChurnFetchResult,
317}
318
319pub(super) fn compute_hotspots(
321 input: HotspotComputationInput<'_>,
322) -> (Vec<HotspotEntry>, Option<HotspotSummary>) {
323 let HotspotComputationInput {
324 opts,
325 config,
326 file_scores,
327 ignore_set,
328 ws_roots,
329 churn_fetch,
330 } = input;
331 let churn_result = churn_fetch.result;
332 let since = churn_fetch.since;
333
334 let shallow_clone = churn_result.shallow_clone;
335 warn_shallow_clone(opts, shallow_clone);
336
337 let min_commits = opts.min_commits.unwrap_or(3);
338 let (max_weighted, max_density) =
339 compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
340
341 let ownership_cfg = &config.health.ownership;
342 let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
343 let codeowners_owned = load_ownership_codeowners(opts, &config.root);
344 let now_secs = std::time::SystemTime::now()
345 .duration_since(std::time::UNIX_EPOCH)
346 .unwrap_or_default()
347 .as_secs();
348 let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
349 author_pool: &churn_result.author_pool,
350 bot_globs,
351 codeowners: codeowners_owned.as_ref(),
352 email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
353 now_secs,
354 });
355
356 let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
357 file_scores,
358 root: &config.root,
359 ignore_set,
360 ws_roots,
361 churn_files: &churn_result.files,
362 min_commits,
363 max_weighted,
364 max_density,
365 ownership_ctx: ownership_ctx.as_ref(),
366 });
367
368 hotspot_entries.sort_by(|a, b| {
369 b.score
370 .partial_cmp(&a.score)
371 .unwrap_or(std::cmp::Ordering::Equal)
372 });
373
374 let files_analyzed = hotspot_entries.len();
375 let summary = HotspotSummary {
376 since: since.display,
377 min_commits,
378 files_analyzed,
379 files_excluded,
380 shallow_clone,
381 };
382
383 if let Some(top) = opts.top {
384 hotspot_entries.truncate(top);
385 }
386
387 (hotspot_entries, Some(summary))
388}
389
390fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
392 if shallow_clone && !opts.quiet {
393 eprintln!(
394 "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
395 Use `git fetch --unshallow` for full history."
396 );
397 if opts.ownership {
398 eprintln!(
399 "Warning: shallow clones inflate single-author dominance, so \
400 ownership signals will be skewed."
401 );
402 }
403 }
404}
405
406fn load_ownership_bot_globs(
408 opts: &HealthOptions<'_>,
409 ownership_cfg: &fallow_config::OwnershipConfig,
410) -> Option<globset::GlobSet> {
411 opts.ownership.then(|| {
412 compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
413 if !opts.quiet {
414 eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
415 }
416 globset::GlobSet::empty()
417 })
418 })
419}
420
421fn load_ownership_codeowners(
423 opts: &HealthOptions<'_>,
424 root: &std::path::Path,
425) -> Option<crate::codeowners::CodeOwners> {
426 opts.ownership
427 .then(|| match crate::codeowners::CodeOwners::load(root, None) {
428 Ok(co) => Some(co),
429 Err(e) => {
430 if !opts.quiet && !e.contains("no CODEOWNERS file found") {
431 eprintln!("Warning: failed to parse CODEOWNERS: {e}");
432 }
433 None
434 }
435 })
436 .flatten()
437}
438
439struct HotspotEntryCtx<'a> {
441 file_scores: &'a [FileHealthScore],
442 root: &'a std::path::Path,
443 ignore_set: &'a globset::GlobSet,
444 ws_roots: Option<&'a [std::path::PathBuf]>,
445 churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
446 min_commits: u32,
447 max_weighted: f64,
448 max_density: f64,
449 ownership_ctx: Option<&'a OwnershipContext<'a>>,
450}
451
452fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
455 let mut hotspot_entries = Vec::new();
456 let mut files_excluded: usize = 0;
457
458 for score in ctx.file_scores {
459 if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
460 continue;
461 }
462
463 let Some(churn) = ctx.churn_files.get(&score.path) else {
464 continue;
465 };
466 if churn.commits < ctx.min_commits {
467 files_excluded += 1;
468 continue;
469 }
470
471 let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
472 let ownership = ctx
473 .ownership_ctx
474 .and_then(|own| compute_ownership(churn, relative, own));
475
476 hotspot_entries.push(HotspotEntry {
477 path: score.path.clone(),
478 score: compute_hotspot_score(
479 churn.weighted_commits,
480 ctx.max_weighted,
481 score.complexity_density,
482 ctx.max_density,
483 ),
484 commits: churn.commits,
485 weighted_commits: churn.weighted_commits,
486 lines_added: churn.lines_added,
487 lines_deleted: churn.lines_deleted,
488 complexity_density: score.complexity_density,
489 fan_in: score.fan_in,
490 trend: churn.trend,
491 ownership,
492 is_test_path: is_test_path(relative),
493 });
494 }
495
496 (hotspot_entries, files_excluded)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
504 TargetChurnOptions {
505 root,
506 target: std::path::Path::new("src/app.ts"),
507 cache_dir: root.join(".fallow"),
508 no_cache: true,
509 since: None,
510 min_commits: None,
511 }
512 }
513
514 fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
515 let path = root.join("src/app.ts");
516 let mut files = rustc_hash::FxHashMap::default();
517 files.insert(
518 path.clone(),
519 crate::churn::FileChurn {
520 path,
521 commits,
522 weighted_commits: 2.5,
523 lines_added: 20,
524 lines_deleted: 5,
525 trend: crate::churn::ChurnTrend::Accelerating,
526 authors: rustc_hash::FxHashMap::default(),
527 },
528 );
529 crate::churn::ChurnResult {
530 files,
531 shallow_clone: false,
532 author_pool: Vec::new(),
533 }
534 }
535
536 #[test]
537 fn target_churn_returns_only_the_requested_qualifying_file() {
538 let root = std::path::Path::new("/project");
539 let options = target_churn_options(root);
540
541 let outcome = analyze_target_churn_with(
542 &options,
543 |_| true,
544 |_, _, _, _| Some((churn_result(root, 4), false)),
545 )
546 .unwrap();
547
548 let TargetChurnOutcome::Found(evidence) = outcome else {
549 panic!("expected qualifying churn evidence");
550 };
551 assert_eq!(evidence.file.path, root.join("src/app.ts"));
552 assert_eq!(evidence.file.commits, 4);
553 assert_eq!(evidence.min_commits, 3);
554 assert_eq!(evidence.since.display, "6 months");
555 }
556
557 #[test]
558 fn target_churn_distinguishes_no_qualifying_history() {
559 let root = std::path::Path::new("/project");
560 let options = target_churn_options(root);
561
562 let outcome = analyze_target_churn_with(
563 &options,
564 |_| true,
565 |_, _, _, _| Some((churn_result(root, 2), false)),
566 )
567 .unwrap();
568
569 assert!(matches!(
570 outcome,
571 TargetChurnOutcome::NoQualifyingChurn {
572 observed_commits: Some(2),
573 min_commits: 3,
574 ..
575 }
576 ));
577 }
578
579 #[test]
580 fn target_churn_distinguishes_git_unavailable() {
581 let root = std::path::Path::new("/project");
582 let options = target_churn_options(root);
583
584 let outcome = analyze_target_churn_with(
585 &options,
586 |_| false,
587 |_, _, _, _| panic!("churn analysis must not run without git"),
588 )
589 .unwrap();
590
591 assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
592 }
593
594 #[test]
595 fn target_churn_surfaces_analysis_failure() {
596 let root = std::path::Path::new("/project");
597 let options = target_churn_options(root);
598
599 let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
600 .expect_err("failed git analysis must remain explicit");
601
602 assert!(error.contains("git churn analysis failed"));
603 }
604
605 #[test]
606 fn hotspot_score_both_maxima_zero() {
607 assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
608 }
609
610 #[test]
611 fn hotspot_score_max_weighted_zero() {
612 assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
613 }
614
615 #[test]
616 fn hotspot_score_max_density_zero() {
617 assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
618 }
619
620 #[test]
621 fn hotspot_score_equal_normalization() {
622 let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
623 assert!((score - 100.0).abs() < f64::EPSILON);
624 }
625
626 #[test]
627 fn hotspot_score_half_values() {
628 let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
629 assert!((score - 25.0).abs() < f64::EPSILON);
630 }
631
632 #[test]
633 fn excluded_no_filters() {
634 let path = std::path::Path::new("/project/src/foo.ts");
635 let root = std::path::Path::new("/project");
636 let ignore_set = globset::GlobSet::empty();
637
638 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
639 }
640
641 #[test]
642 fn excluded_workspace_filter_mismatch() {
643 let path = std::path::Path::new("/project/packages/b/src/foo.ts");
644 let root = std::path::Path::new("/project");
645 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
646 let ignore_set = globset::GlobSet::empty();
647
648 assert!(is_excluded_from_hotspots(
649 path,
650 root,
651 &ignore_set,
652 Some(&ws_roots)
653 ));
654 }
655
656 #[test]
657 fn excluded_workspace_filter_match() {
658 let path = std::path::Path::new("/project/packages/a/src/foo.ts");
659 let root = std::path::Path::new("/project");
660 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
661 let ignore_set = globset::GlobSet::empty();
662
663 assert!(!is_excluded_from_hotspots(
664 path,
665 root,
666 &ignore_set,
667 Some(&ws_roots)
668 ));
669 }
670
671 #[test]
672 fn excluded_matching_glob() {
673 let path = std::path::Path::new("/project/src/generated/types.ts");
674 let root = std::path::Path::new("/project");
675 let mut builder = globset::GlobSetBuilder::new();
676 builder.add(globset::Glob::new("src/generated/**").unwrap());
677 let ignore_set = builder.build().unwrap();
678
679 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
680 }
681
682 #[test]
683 fn excluded_non_matching_glob() {
684 let path = std::path::Path::new("/project/src/components/Button.tsx");
685 let root = std::path::Path::new("/project");
686 let mut builder = globset::GlobSetBuilder::new();
687 builder.add(globset::Glob::new("src/generated/**").unwrap());
688 let ignore_set = builder.build().unwrap();
689
690 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
691 }
692
693 #[test]
694 fn normalization_maxima_empty_input() {
695 let scores: Vec<FileHealthScore> = vec![];
696 let churn_files = rustc_hash::FxHashMap::default();
697
698 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
699 assert!((max_w).abs() < f64::EPSILON);
700 assert!((max_d).abs() < f64::EPSILON);
701 }
702
703 #[test]
704 fn normalization_maxima_single_file() {
705 let scores = vec![FileHealthScore {
706 path: std::path::PathBuf::from("/src/foo.ts"),
707 fan_in: 0,
708 fan_out: 0,
709 dead_code_ratio: 0.0,
710 complexity_density: 0.75,
711 maintainability_index: 80.0,
712 total_cyclomatic: 15,
713 total_cognitive: 10,
714 function_count: 3,
715 lines: 20,
716 crap_max: 0.0,
717 crap_above_threshold: 0,
718 crap_exempted: 0,
719 crap_effective_threshold: None,
720 }];
721 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
722 rustc_hash::FxHashMap::default();
723 churn_files.insert(
724 std::path::PathBuf::from("/src/foo.ts"),
725 crate::churn::FileChurn {
726 path: std::path::PathBuf::from("/src/foo.ts"),
727 commits: 5,
728 weighted_commits: 4.2,
729 lines_added: 100,
730 lines_deleted: 20,
731 trend: crate::churn::ChurnTrend::Stable,
732 authors: rustc_hash::FxHashMap::default(),
733 },
734 );
735
736 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
737 assert!((max_w - 4.2).abs() < f64::EPSILON);
738 assert!((max_d - 0.75).abs() < f64::EPSILON);
739 }
740
741 #[test]
745 fn normalization_maxima_ignore_crap_exemption_fields() {
746 let base = FileHealthScore {
747 path: std::path::PathBuf::from("/src/foo.ts"),
748 fan_in: 0,
749 fan_out: 0,
750 dead_code_ratio: 0.0,
751 complexity_density: 0.75,
752 maintainability_index: 80.0,
753 total_cyclomatic: 15,
754 total_cognitive: 10,
755 function_count: 3,
756 lines: 20,
757 crap_max: 110.0,
758 crap_above_threshold: 2,
759 crap_exempted: 0,
760 crap_effective_threshold: None,
761 };
762 let exempt = FileHealthScore {
763 crap_above_threshold: 0,
764 crap_exempted: 2,
765 crap_effective_threshold: Some(500.0),
766 ..base.clone()
767 };
768 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
769 rustc_hash::FxHashMap::default();
770 churn_files.insert(
771 std::path::PathBuf::from("/src/foo.ts"),
772 crate::churn::FileChurn {
773 path: std::path::PathBuf::from("/src/foo.ts"),
774 commits: 5,
775 weighted_commits: 4.2,
776 lines_added: 100,
777 lines_deleted: 20,
778 trend: crate::churn::ChurnTrend::Stable,
779 authors: rustc_hash::FxHashMap::default(),
780 },
781 );
782
783 let flagged = compute_normalization_maxima(&[base], &churn_files, 3);
784 let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
785 assert_eq!(flagged, exempted);
786 }
787
788 #[test]
789 fn normalization_maxima_below_min_commits() {
790 let scores = vec![FileHealthScore {
791 path: std::path::PathBuf::from("/src/foo.ts"),
792 fan_in: 0,
793 fan_out: 0,
794 dead_code_ratio: 0.0,
795 complexity_density: 0.75,
796 maintainability_index: 80.0,
797 total_cyclomatic: 15,
798 total_cognitive: 10,
799 function_count: 3,
800 lines: 20,
801 crap_max: 0.0,
802 crap_above_threshold: 0,
803 crap_exempted: 0,
804 crap_effective_threshold: None,
805 }];
806 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
807 rustc_hash::FxHashMap::default();
808 churn_files.insert(
809 std::path::PathBuf::from("/src/foo.ts"),
810 crate::churn::FileChurn {
811 path: std::path::PathBuf::from("/src/foo.ts"),
812 commits: 2, weighted_commits: 4.2,
814 lines_added: 100,
815 lines_deleted: 20,
816 trend: crate::churn::ChurnTrend::Stable,
817 authors: rustc_hash::FxHashMap::default(),
818 },
819 );
820
821 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
822 assert!((max_w).abs() < f64::EPSILON);
823 assert!((max_d).abs() < f64::EPSILON);
824 }
825
826 #[test]
827 fn normalization_maxima_all_zeros() {
828 let scores = vec![FileHealthScore {
829 path: std::path::PathBuf::from("/src/foo.ts"),
830 fan_in: 0,
831 fan_out: 0,
832 dead_code_ratio: 0.0,
833 complexity_density: 0.0,
834 maintainability_index: 100.0,
835 total_cyclomatic: 0,
836 total_cognitive: 0,
837 function_count: 1,
838 lines: 10,
839 crap_max: 0.0,
840 crap_above_threshold: 0,
841 crap_exempted: 0,
842 crap_effective_threshold: None,
843 }];
844 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
845 rustc_hash::FxHashMap::default();
846 churn_files.insert(
847 std::path::PathBuf::from("/src/foo.ts"),
848 crate::churn::FileChurn {
849 path: std::path::PathBuf::from("/src/foo.ts"),
850 commits: 5,
851 weighted_commits: 0.0,
852 lines_added: 0,
853 lines_deleted: 0,
854 trend: crate::churn::ChurnTrend::Stable,
855 authors: rustc_hash::FxHashMap::default(),
856 },
857 );
858
859 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
860 assert!((max_w).abs() < f64::EPSILON);
861 assert!((max_d).abs() < f64::EPSILON);
862 }
863
864 #[test]
865 fn hotspot_score_high_churn_low_complexity() {
866 let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
867 assert!((score - 10.0).abs() < f64::EPSILON);
868 }
869
870 #[test]
871 fn hotspot_score_low_churn_high_complexity() {
872 let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
873 assert!((score - 10.0).abs() < f64::EPSILON);
874 }
875
876 #[test]
877 fn hotspot_score_rounding() {
878 let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
879 assert!((score - 11.1).abs() < f64::EPSILON);
880 }
881
882 #[test]
883 fn hotspot_score_very_small_values() {
884 let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
885 assert!((score).abs() < 0.1);
886 }
887
888 #[test]
889 fn hotspot_score_weighted_exceeds_max() {
890 let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
891 assert!((score - 75.0).abs() < f64::EPSILON);
892 }
893
894 #[test]
895 fn normalization_maxima_multiple_files_picks_max() {
896 let scores = vec![
897 FileHealthScore {
898 path: std::path::PathBuf::from("/src/a.ts"),
899 fan_in: 0,
900 fan_out: 0,
901 dead_code_ratio: 0.0,
902 complexity_density: 0.5,
903 maintainability_index: 80.0,
904 total_cyclomatic: 10,
905 total_cognitive: 5,
906 function_count: 2,
907 lines: 50,
908 crap_max: 0.0,
909 crap_above_threshold: 0,
910 crap_exempted: 0,
911 crap_effective_threshold: None,
912 },
913 FileHealthScore {
914 path: std::path::PathBuf::from("/src/b.ts"),
915 fan_in: 0,
916 fan_out: 0,
917 dead_code_ratio: 0.0,
918 complexity_density: 1.2, maintainability_index: 60.0,
920 total_cyclomatic: 30,
921 total_cognitive: 20,
922 function_count: 5,
923 lines: 100,
924 crap_max: 0.0,
925 crap_above_threshold: 0,
926 crap_exempted: 0,
927 crap_effective_threshold: None,
928 },
929 FileHealthScore {
930 path: std::path::PathBuf::from("/src/c.ts"),
931 fan_in: 0,
932 fan_out: 0,
933 dead_code_ratio: 0.0,
934 complexity_density: 0.8,
935 maintainability_index: 70.0,
936 total_cyclomatic: 20,
937 total_cognitive: 15,
938 function_count: 4,
939 lines: 80,
940 crap_max: 0.0,
941 crap_above_threshold: 0,
942 crap_exempted: 0,
943 crap_effective_threshold: None,
944 },
945 ];
946 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
947 rustc_hash::FxHashMap::default();
948 churn_files.insert(
949 std::path::PathBuf::from("/src/a.ts"),
950 crate::churn::FileChurn {
951 path: std::path::PathBuf::from("/src/a.ts"),
952 commits: 5,
953 weighted_commits: 3.0,
954 lines_added: 50,
955 lines_deleted: 10,
956 trend: crate::churn::ChurnTrend::Stable,
957 authors: rustc_hash::FxHashMap::default(),
958 },
959 );
960 churn_files.insert(
961 std::path::PathBuf::from("/src/b.ts"),
962 crate::churn::FileChurn {
963 path: std::path::PathBuf::from("/src/b.ts"),
964 commits: 10,
965 weighted_commits: 8.5, lines_added: 200,
967 lines_deleted: 50,
968 trend: crate::churn::ChurnTrend::Accelerating,
969 authors: rustc_hash::FxHashMap::default(),
970 },
971 );
972 churn_files.insert(
973 std::path::PathBuf::from("/src/c.ts"),
974 crate::churn::FileChurn {
975 path: std::path::PathBuf::from("/src/c.ts"),
976 commits: 7,
977 weighted_commits: 5.0,
978 lines_added: 100,
979 lines_deleted: 30,
980 trend: crate::churn::ChurnTrend::Cooling,
981 authors: rustc_hash::FxHashMap::default(),
982 },
983 );
984
985 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
986 assert!((max_w - 8.5).abs() < f64::EPSILON);
987 assert!((max_d - 1.2).abs() < f64::EPSILON);
988 }
989
990 #[test]
991 fn normalization_maxima_mixed_above_and_below_threshold() {
992 let scores = vec![
993 FileHealthScore {
994 path: std::path::PathBuf::from("/src/frequent.ts"),
995 fan_in: 0,
996 fan_out: 0,
997 dead_code_ratio: 0.0,
998 complexity_density: 0.4,
999 maintainability_index: 85.0,
1000 total_cyclomatic: 8,
1001 total_cognitive: 4,
1002 function_count: 2,
1003 lines: 40,
1004 crap_max: 0.0,
1005 crap_above_threshold: 0,
1006 crap_exempted: 0,
1007 crap_effective_threshold: None,
1008 },
1009 FileHealthScore {
1010 path: std::path::PathBuf::from("/src/rare.ts"),
1011 fan_in: 0,
1012 fan_out: 0,
1013 dead_code_ratio: 0.0,
1014 complexity_density: 2.0, maintainability_index: 50.0,
1016 total_cyclomatic: 40,
1017 total_cognitive: 30,
1018 function_count: 8,
1019 lines: 200,
1020 crap_max: 0.0,
1021 crap_above_threshold: 0,
1022 crap_exempted: 0,
1023 crap_effective_threshold: None,
1024 },
1025 ];
1026 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1027 rustc_hash::FxHashMap::default();
1028 churn_files.insert(
1029 std::path::PathBuf::from("/src/frequent.ts"),
1030 crate::churn::FileChurn {
1031 path: std::path::PathBuf::from("/src/frequent.ts"),
1032 commits: 10,
1033 weighted_commits: 7.0,
1034 lines_added: 150,
1035 lines_deleted: 40,
1036 trend: crate::churn::ChurnTrend::Stable,
1037 authors: rustc_hash::FxHashMap::default(),
1038 },
1039 );
1040 churn_files.insert(
1041 std::path::PathBuf::from("/src/rare.ts"),
1042 crate::churn::FileChurn {
1043 path: std::path::PathBuf::from("/src/rare.ts"),
1044 commits: 1, weighted_commits: 0.9,
1046 lines_added: 10,
1047 lines_deleted: 2,
1048 trend: crate::churn::ChurnTrend::Cooling,
1049 authors: rustc_hash::FxHashMap::default(),
1050 },
1051 );
1052
1053 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1054 assert!((max_w - 7.0).abs() < f64::EPSILON);
1055 assert!((max_d - 0.4).abs() < f64::EPSILON);
1056 }
1057
1058 #[test]
1059 fn normalization_maxima_file_score_without_churn() {
1060 let scores = vec![FileHealthScore {
1061 path: std::path::PathBuf::from("/src/no_churn.ts"),
1062 fan_in: 0,
1063 fan_out: 0,
1064 dead_code_ratio: 0.0,
1065 complexity_density: 5.0,
1066 maintainability_index: 30.0,
1067 total_cyclomatic: 100,
1068 total_cognitive: 80,
1069 function_count: 20,
1070 lines: 500,
1071 crap_max: 0.0,
1072 crap_above_threshold: 0,
1073 crap_exempted: 0,
1074 crap_effective_threshold: None,
1075 }];
1076 let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1077 rustc_hash::FxHashMap::default();
1078
1079 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1080 assert!((max_w).abs() < f64::EPSILON);
1081 assert!((max_d).abs() < f64::EPSILON);
1082 }
1083
1084 #[test]
1085 fn normalization_maxima_min_commits_zero() {
1086 let scores = vec![FileHealthScore {
1087 path: std::path::PathBuf::from("/src/foo.ts"),
1088 fan_in: 0,
1089 fan_out: 0,
1090 dead_code_ratio: 0.0,
1091 complexity_density: 0.3,
1092 maintainability_index: 90.0,
1093 total_cyclomatic: 3,
1094 total_cognitive: 2,
1095 function_count: 1,
1096 lines: 10,
1097 crap_max: 0.0,
1098 crap_above_threshold: 0,
1099 crap_exempted: 0,
1100 crap_effective_threshold: None,
1101 }];
1102 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1103 rustc_hash::FxHashMap::default();
1104 churn_files.insert(
1105 std::path::PathBuf::from("/src/foo.ts"),
1106 crate::churn::FileChurn {
1107 path: std::path::PathBuf::from("/src/foo.ts"),
1108 commits: 0,
1109 weighted_commits: 0.0,
1110 lines_added: 0,
1111 lines_deleted: 0,
1112 trend: crate::churn::ChurnTrend::Stable,
1113 authors: rustc_hash::FxHashMap::default(),
1114 },
1115 );
1116
1117 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1118 assert!((max_w).abs() < f64::EPSILON);
1119 assert!((max_d - 0.3).abs() < f64::EPSILON);
1120 }
1121
1122 #[test]
1123 fn normalization_maxima_exactly_at_threshold() {
1124 let scores = vec![FileHealthScore {
1125 path: std::path::PathBuf::from("/src/foo.ts"),
1126 fan_in: 0,
1127 fan_out: 0,
1128 dead_code_ratio: 0.0,
1129 complexity_density: 1.5,
1130 maintainability_index: 65.0,
1131 total_cyclomatic: 25,
1132 total_cognitive: 18,
1133 function_count: 5,
1134 lines: 120,
1135 crap_max: 0.0,
1136 crap_above_threshold: 0,
1137 crap_exempted: 0,
1138 crap_effective_threshold: None,
1139 }];
1140 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1141 rustc_hash::FxHashMap::default();
1142 churn_files.insert(
1143 std::path::PathBuf::from("/src/foo.ts"),
1144 crate::churn::FileChurn {
1145 path: std::path::PathBuf::from("/src/foo.ts"),
1146 commits: 3, weighted_commits: 2.8,
1148 lines_added: 60,
1149 lines_deleted: 15,
1150 trend: crate::churn::ChurnTrend::Stable,
1151 authors: rustc_hash::FxHashMap::default(),
1152 },
1153 );
1154
1155 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1156 assert!((max_w - 2.8).abs() < f64::EPSILON);
1157 assert!((max_d - 1.5).abs() < f64::EPSILON);
1158 }
1159
1160 #[test]
1161 fn excluded_workspace_and_glob_combined() {
1162 let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1163 let root = std::path::Path::new("/project");
1164 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1165 let mut builder = globset::GlobSetBuilder::new();
1166 builder.add(globset::Glob::new("**/generated/**").unwrap());
1167 let ignore_set = builder.build().unwrap();
1168
1169 assert!(is_excluded_from_hotspots(
1170 path,
1171 root,
1172 &ignore_set,
1173 Some(&ws_roots)
1174 ));
1175 }
1176
1177 #[test]
1178 fn excluded_workspace_match_but_glob_no_match() {
1179 let path = std::path::Path::new("/project/packages/a/src/index.ts");
1180 let root = std::path::Path::new("/project");
1181 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1182 let mut builder = globset::GlobSetBuilder::new();
1183 builder.add(globset::Glob::new("**/generated/**").unwrap());
1184 let ignore_set = builder.build().unwrap();
1185
1186 assert!(!is_excluded_from_hotspots(
1187 path,
1188 root,
1189 &ignore_set,
1190 Some(&ws_roots)
1191 ));
1192 }
1193
1194 #[test]
1195 fn excluded_path_equals_root() {
1196 let path = std::path::Path::new("/project");
1197 let root = std::path::Path::new("/project");
1198 let ignore_set = globset::GlobSet::empty();
1199
1200 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1201 }
1202
1203 #[test]
1204 fn excluded_path_outside_root() {
1205 let path = std::path::Path::new("/other/src/foo.ts");
1206 let root = std::path::Path::new("/project");
1207 let mut builder = globset::GlobSetBuilder::new();
1208 builder.add(globset::Glob::new("src/foo.ts").unwrap());
1209 let ignore_set = builder.build().unwrap();
1210
1211 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1212 }
1213
1214 #[test]
1215 fn excluded_multiple_globs_first_matches() {
1216 let path = std::path::Path::new("/project/dist/bundle.js");
1217 let root = std::path::Path::new("/project");
1218 let mut builder = globset::GlobSetBuilder::new();
1219 builder.add(globset::Glob::new("dist/**").unwrap());
1220 builder.add(globset::Glob::new("node_modules/**").unwrap());
1221 let ignore_set = builder.build().unwrap();
1222
1223 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1224 }
1225
1226 #[test]
1227 fn excluded_multiple_globs_second_matches() {
1228 let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1229 let root = std::path::Path::new("/project");
1230 let mut builder = globset::GlobSetBuilder::new();
1231 builder.add(globset::Glob::new("dist/**").unwrap());
1232 builder.add(globset::Glob::new("node_modules/**").unwrap());
1233 let ignore_set = builder.build().unwrap();
1234
1235 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1236 }
1237
1238 #[test]
1239 fn excluded_multiple_globs_none_matches() {
1240 let path = std::path::Path::new("/project/src/app.ts");
1241 let root = std::path::Path::new("/project");
1242 let mut builder = globset::GlobSetBuilder::new();
1243 builder.add(globset::Glob::new("dist/**").unwrap());
1244 builder.add(globset::Glob::new("node_modules/**").unwrap());
1245 let ignore_set = builder.build().unwrap();
1246
1247 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1248 }
1249}