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::{ClockProvenance, ClockSource, 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 window: crate::churn::ChurnWindow::Imported,
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 warn_unpinned_clock(opts, churn_result.clock);
337
338 let min_commits = opts.min_commits.unwrap_or(3);
339 let (max_weighted, max_density) =
340 compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
341
342 let ownership_cfg = &config.health.ownership;
343 let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
344 let codeowners_owned = load_ownership_codeowners(opts, &config.root);
345 let now_secs = churn_result.clock.epoch_secs();
349 let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
350 author_pool: &churn_result.author_pool,
351 bot_globs,
352 codeowners: codeowners_owned.as_ref(),
353 email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
354 now_secs,
355 });
356
357 let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
358 file_scores,
359 root: &config.root,
360 ignore_set,
361 ws_roots,
362 churn_files: &churn_result.files,
363 min_commits,
364 max_weighted,
365 max_density,
366 ownership_ctx: ownership_ctx.as_ref(),
367 });
368
369 hotspot_entries.sort_by(|a, b| {
370 b.score
371 .partial_cmp(&a.score)
372 .unwrap_or(std::cmp::Ordering::Equal)
373 });
374
375 let files_analyzed = hotspot_entries.len();
376 let summary = HotspotSummary {
377 since: since.display,
378 min_commits,
379 files_analyzed,
380 files_excluded,
381 shallow_clone,
382 clock: Some(clock_provenance(churn_result.clock)),
383 };
384
385 if let Some(top) = opts.top {
386 hotspot_entries.truncate(top);
387 }
388
389 (hotspot_entries, Some(summary))
390}
391
392fn clock_provenance(clock: crate::clock::AnalysisClock) -> ClockProvenance {
398 ClockProvenance {
399 source: match clock.source() {
400 crate::clock::AnalysisClockSource::Environment => ClockSource::Environment,
401 crate::clock::AnalysisClockSource::HeadCommit => ClockSource::HeadCommit,
402 crate::clock::AnalysisClockSource::WallClock => ClockSource::WallClock,
403 },
404 epoch_secs: clock.epoch_secs(),
405 reproducible: clock.is_reproducible(),
406 }
407}
408
409fn warn_unpinned_clock(opts: &HealthOptions<'_>, clock: crate::clock::AnalysisClock) {
418 if !clock.is_reproducible() && !opts.quiet {
419 eprintln!(
420 "Warning: no commit timestamp available, so churn recency and \
421 ownership staleness were measured against the wall clock and will \
422 drift between runs. Set FALLOW_CLOCK_EPOCH to pin them."
423 );
424 }
425}
426
427fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
429 if shallow_clone && !opts.quiet {
430 eprintln!(
431 "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
432 Use `git fetch --unshallow` for full history."
433 );
434 if opts.ownership {
435 eprintln!(
436 "Warning: shallow clones inflate single-author dominance, so \
437 ownership signals will be skewed."
438 );
439 }
440 }
441}
442
443fn load_ownership_bot_globs(
445 opts: &HealthOptions<'_>,
446 ownership_cfg: &fallow_config::OwnershipConfig,
447) -> Option<globset::GlobSet> {
448 opts.ownership.then(|| {
449 compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
450 if !opts.quiet {
451 eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
452 }
453 globset::GlobSet::empty()
454 })
455 })
456}
457
458fn load_ownership_codeowners(
460 opts: &HealthOptions<'_>,
461 root: &std::path::Path,
462) -> Option<crate::codeowners::CodeOwners> {
463 opts.ownership
464 .then(|| match crate::codeowners::CodeOwners::load(root, None) {
465 Ok(co) => Some(co),
466 Err(e) => {
467 if !opts.quiet && !e.contains("no CODEOWNERS file found") {
468 eprintln!("Warning: failed to parse CODEOWNERS: {e}");
469 }
470 None
471 }
472 })
473 .flatten()
474}
475
476struct HotspotEntryCtx<'a> {
478 file_scores: &'a [FileHealthScore],
479 root: &'a std::path::Path,
480 ignore_set: &'a globset::GlobSet,
481 ws_roots: Option<&'a [std::path::PathBuf]>,
482 churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
483 min_commits: u32,
484 max_weighted: f64,
485 max_density: f64,
486 ownership_ctx: Option<&'a OwnershipContext<'a>>,
487}
488
489fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
492 let mut hotspot_entries = Vec::new();
493 let mut files_excluded: usize = 0;
494
495 for score in ctx.file_scores {
496 if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
497 continue;
498 }
499
500 let Some(churn) = ctx.churn_files.get(&score.path) else {
501 continue;
502 };
503 if churn.commits < ctx.min_commits {
504 files_excluded += 1;
505 continue;
506 }
507
508 let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
509 let ownership = ctx
510 .ownership_ctx
511 .and_then(|own| compute_ownership(churn, relative, own));
512
513 hotspot_entries.push(HotspotEntry {
514 path: score.path.clone(),
515 score: compute_hotspot_score(
516 churn.weighted_commits,
517 ctx.max_weighted,
518 score.complexity_density,
519 ctx.max_density,
520 ),
521 commits: churn.commits,
522 weighted_commits: churn.weighted_commits,
523 lines_added: churn.lines_added,
524 lines_deleted: churn.lines_deleted,
525 complexity_density: score.complexity_density,
526 fan_in: score.fan_in,
527 trend: churn.trend,
528 ownership,
529 is_test_path: is_test_path(relative),
530 });
531 }
532
533 (hotspot_entries, files_excluded)
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
541 TargetChurnOptions {
542 root,
543 target: std::path::Path::new("src/app.ts"),
544 cache_dir: root.join(".fallow"),
545 no_cache: true,
546 since: None,
547 min_commits: None,
548 }
549 }
550
551 fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
552 let path = root.join("src/app.ts");
553 let mut files = rustc_hash::FxHashMap::default();
554 files.insert(
555 path.clone(),
556 crate::churn::FileChurn {
557 path,
558 commits,
559 weighted_commits: 2.5,
560 lines_added: 20,
561 lines_deleted: 5,
562 trend: crate::churn::ChurnTrend::Accelerating,
563 authors: rustc_hash::FxHashMap::default(),
564 },
565 );
566 crate::churn::ChurnResult {
567 files,
568 shallow_clone: false,
569 author_pool: Vec::new(),
570 clock: crate::clock::AnalysisClock::pinned(1_788_782_400),
571 }
572 }
573
574 #[test]
575 fn target_churn_returns_only_the_requested_qualifying_file() {
576 let root = std::path::Path::new("/project");
577 let options = target_churn_options(root);
578
579 let outcome = analyze_target_churn_with(
580 &options,
581 |_| true,
582 |_, _, _, _| Some((churn_result(root, 4), false)),
583 )
584 .unwrap();
585
586 let TargetChurnOutcome::Found(evidence) = outcome else {
587 panic!("expected qualifying churn evidence");
588 };
589 assert_eq!(evidence.file.path, root.join("src/app.ts"));
590 assert_eq!(evidence.file.commits, 4);
591 assert_eq!(evidence.min_commits, 3);
592 assert_eq!(evidence.since.display, "6 months");
593 }
594
595 #[test]
596 fn target_churn_distinguishes_no_qualifying_history() {
597 let root = std::path::Path::new("/project");
598 let options = target_churn_options(root);
599
600 let outcome = analyze_target_churn_with(
601 &options,
602 |_| true,
603 |_, _, _, _| Some((churn_result(root, 2), false)),
604 )
605 .unwrap();
606
607 assert!(matches!(
608 outcome,
609 TargetChurnOutcome::NoQualifyingChurn {
610 observed_commits: Some(2),
611 min_commits: 3,
612 ..
613 }
614 ));
615 }
616
617 #[test]
618 fn target_churn_distinguishes_git_unavailable() {
619 let root = std::path::Path::new("/project");
620 let options = target_churn_options(root);
621
622 let outcome = analyze_target_churn_with(
623 &options,
624 |_| false,
625 |_, _, _, _| panic!("churn analysis must not run without git"),
626 )
627 .unwrap();
628
629 assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
630 }
631
632 #[test]
633 fn target_churn_surfaces_analysis_failure() {
634 let root = std::path::Path::new("/project");
635 let options = target_churn_options(root);
636
637 let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
638 .expect_err("failed git analysis must remain explicit");
639
640 assert!(error.contains("git churn analysis failed"));
641 }
642
643 #[test]
644 fn hotspot_score_both_maxima_zero() {
645 assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
646 }
647
648 #[test]
649 fn hotspot_score_max_weighted_zero() {
650 assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
651 }
652
653 #[test]
654 fn hotspot_score_max_density_zero() {
655 assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
656 }
657
658 #[test]
659 fn hotspot_score_equal_normalization() {
660 let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
661 assert!((score - 100.0).abs() < f64::EPSILON);
662 }
663
664 #[test]
665 fn hotspot_score_half_values() {
666 let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
667 assert!((score - 25.0).abs() < f64::EPSILON);
668 }
669
670 #[test]
671 fn excluded_no_filters() {
672 let path = std::path::Path::new("/project/src/foo.ts");
673 let root = std::path::Path::new("/project");
674 let ignore_set = globset::GlobSet::empty();
675
676 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
677 }
678
679 #[test]
680 fn excluded_workspace_filter_mismatch() {
681 let path = std::path::Path::new("/project/packages/b/src/foo.ts");
682 let root = std::path::Path::new("/project");
683 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
684 let ignore_set = globset::GlobSet::empty();
685
686 assert!(is_excluded_from_hotspots(
687 path,
688 root,
689 &ignore_set,
690 Some(&ws_roots)
691 ));
692 }
693
694 #[test]
695 fn excluded_workspace_filter_match() {
696 let path = std::path::Path::new("/project/packages/a/src/foo.ts");
697 let root = std::path::Path::new("/project");
698 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
699 let ignore_set = globset::GlobSet::empty();
700
701 assert!(!is_excluded_from_hotspots(
702 path,
703 root,
704 &ignore_set,
705 Some(&ws_roots)
706 ));
707 }
708
709 #[test]
710 fn excluded_matching_glob() {
711 let path = std::path::Path::new("/project/src/generated/types.ts");
712 let root = std::path::Path::new("/project");
713 let mut builder = globset::GlobSetBuilder::new();
714 builder.add(globset::Glob::new("src/generated/**").unwrap());
715 let ignore_set = builder.build().unwrap();
716
717 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
718 }
719
720 #[test]
721 fn excluded_non_matching_glob() {
722 let path = std::path::Path::new("/project/src/components/Button.tsx");
723 let root = std::path::Path::new("/project");
724 let mut builder = globset::GlobSetBuilder::new();
725 builder.add(globset::Glob::new("src/generated/**").unwrap());
726 let ignore_set = builder.build().unwrap();
727
728 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
729 }
730
731 #[test]
732 fn normalization_maxima_empty_input() {
733 let scores: Vec<FileHealthScore> = vec![];
734 let churn_files = rustc_hash::FxHashMap::default();
735
736 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
737 assert!((max_w).abs() < f64::EPSILON);
738 assert!((max_d).abs() < f64::EPSILON);
739 }
740
741 #[test]
742 fn normalization_maxima_single_file() {
743 let scores = vec![FileHealthScore {
744 path: std::path::PathBuf::from("/src/foo.ts"),
745 fan_in: 0,
746 fan_out: 0,
747 dead_code_ratio: 0.0,
748 complexity_density: 0.75,
749 maintainability_index: 80.0,
750 total_cyclomatic: 15,
751 total_cognitive: 10,
752 function_count: 3,
753 lines: 20,
754 crap_max: 0.0,
755 crap_above_threshold: 0,
756 crap_exempted: 0,
757 crap_effective_threshold: None,
758 }];
759 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
760 rustc_hash::FxHashMap::default();
761 churn_files.insert(
762 std::path::PathBuf::from("/src/foo.ts"),
763 crate::churn::FileChurn {
764 path: std::path::PathBuf::from("/src/foo.ts"),
765 commits: 5,
766 weighted_commits: 4.2,
767 lines_added: 100,
768 lines_deleted: 20,
769 trend: crate::churn::ChurnTrend::Stable,
770 authors: rustc_hash::FxHashMap::default(),
771 },
772 );
773
774 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
775 assert!((max_w - 4.2).abs() < f64::EPSILON);
776 assert!((max_d - 0.75).abs() < f64::EPSILON);
777 }
778
779 #[test]
783 fn normalization_maxima_ignore_crap_exemption_fields() {
784 let base = FileHealthScore {
785 path: std::path::PathBuf::from("/src/foo.ts"),
786 fan_in: 0,
787 fan_out: 0,
788 dead_code_ratio: 0.0,
789 complexity_density: 0.75,
790 maintainability_index: 80.0,
791 total_cyclomatic: 15,
792 total_cognitive: 10,
793 function_count: 3,
794 lines: 20,
795 crap_max: 110.0,
796 crap_above_threshold: 2,
797 crap_exempted: 0,
798 crap_effective_threshold: None,
799 };
800 let exempt = FileHealthScore {
801 crap_above_threshold: 0,
802 crap_exempted: 2,
803 crap_effective_threshold: Some(500.0),
804 ..base.clone()
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: 5,
813 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 flagged = compute_normalization_maxima(&[base], &churn_files, 3);
822 let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
823 assert_eq!(flagged, exempted);
824 }
825
826 #[test]
827 fn normalization_maxima_below_min_commits() {
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.75,
834 maintainability_index: 80.0,
835 total_cyclomatic: 15,
836 total_cognitive: 10,
837 function_count: 3,
838 lines: 20,
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: 2, weighted_commits: 4.2,
852 lines_added: 100,
853 lines_deleted: 20,
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 normalization_maxima_all_zeros() {
866 let scores = vec![FileHealthScore {
867 path: std::path::PathBuf::from("/src/foo.ts"),
868 fan_in: 0,
869 fan_out: 0,
870 dead_code_ratio: 0.0,
871 complexity_density: 0.0,
872 maintainability_index: 100.0,
873 total_cyclomatic: 0,
874 total_cognitive: 0,
875 function_count: 1,
876 lines: 10,
877 crap_max: 0.0,
878 crap_above_threshold: 0,
879 crap_exempted: 0,
880 crap_effective_threshold: None,
881 }];
882 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
883 rustc_hash::FxHashMap::default();
884 churn_files.insert(
885 std::path::PathBuf::from("/src/foo.ts"),
886 crate::churn::FileChurn {
887 path: std::path::PathBuf::from("/src/foo.ts"),
888 commits: 5,
889 weighted_commits: 0.0,
890 lines_added: 0,
891 lines_deleted: 0,
892 trend: crate::churn::ChurnTrend::Stable,
893 authors: rustc_hash::FxHashMap::default(),
894 },
895 );
896
897 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
898 assert!((max_w).abs() < f64::EPSILON);
899 assert!((max_d).abs() < f64::EPSILON);
900 }
901
902 #[test]
903 fn hotspot_score_high_churn_low_complexity() {
904 let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
905 assert!((score - 10.0).abs() < f64::EPSILON);
906 }
907
908 #[test]
909 fn hotspot_score_low_churn_high_complexity() {
910 let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
911 assert!((score - 10.0).abs() < f64::EPSILON);
912 }
913
914 #[test]
915 fn hotspot_score_rounding() {
916 let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
917 assert!((score - 11.1).abs() < f64::EPSILON);
918 }
919
920 #[test]
921 fn hotspot_score_very_small_values() {
922 let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
923 assert!((score).abs() < 0.1);
924 }
925
926 #[test]
927 fn hotspot_score_weighted_exceeds_max() {
928 let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
929 assert!((score - 75.0).abs() < f64::EPSILON);
930 }
931
932 #[test]
933 fn normalization_maxima_multiple_files_picks_max() {
934 let scores = vec![
935 FileHealthScore {
936 path: std::path::PathBuf::from("/src/a.ts"),
937 fan_in: 0,
938 fan_out: 0,
939 dead_code_ratio: 0.0,
940 complexity_density: 0.5,
941 maintainability_index: 80.0,
942 total_cyclomatic: 10,
943 total_cognitive: 5,
944 function_count: 2,
945 lines: 50,
946 crap_max: 0.0,
947 crap_above_threshold: 0,
948 crap_exempted: 0,
949 crap_effective_threshold: None,
950 },
951 FileHealthScore {
952 path: std::path::PathBuf::from("/src/b.ts"),
953 fan_in: 0,
954 fan_out: 0,
955 dead_code_ratio: 0.0,
956 complexity_density: 1.2, maintainability_index: 60.0,
958 total_cyclomatic: 30,
959 total_cognitive: 20,
960 function_count: 5,
961 lines: 100,
962 crap_max: 0.0,
963 crap_above_threshold: 0,
964 crap_exempted: 0,
965 crap_effective_threshold: None,
966 },
967 FileHealthScore {
968 path: std::path::PathBuf::from("/src/c.ts"),
969 fan_in: 0,
970 fan_out: 0,
971 dead_code_ratio: 0.0,
972 complexity_density: 0.8,
973 maintainability_index: 70.0,
974 total_cyclomatic: 20,
975 total_cognitive: 15,
976 function_count: 4,
977 lines: 80,
978 crap_max: 0.0,
979 crap_above_threshold: 0,
980 crap_exempted: 0,
981 crap_effective_threshold: None,
982 },
983 ];
984 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
985 rustc_hash::FxHashMap::default();
986 churn_files.insert(
987 std::path::PathBuf::from("/src/a.ts"),
988 crate::churn::FileChurn {
989 path: std::path::PathBuf::from("/src/a.ts"),
990 commits: 5,
991 weighted_commits: 3.0,
992 lines_added: 50,
993 lines_deleted: 10,
994 trend: crate::churn::ChurnTrend::Stable,
995 authors: rustc_hash::FxHashMap::default(),
996 },
997 );
998 churn_files.insert(
999 std::path::PathBuf::from("/src/b.ts"),
1000 crate::churn::FileChurn {
1001 path: std::path::PathBuf::from("/src/b.ts"),
1002 commits: 10,
1003 weighted_commits: 8.5, lines_added: 200,
1005 lines_deleted: 50,
1006 trend: crate::churn::ChurnTrend::Accelerating,
1007 authors: rustc_hash::FxHashMap::default(),
1008 },
1009 );
1010 churn_files.insert(
1011 std::path::PathBuf::from("/src/c.ts"),
1012 crate::churn::FileChurn {
1013 path: std::path::PathBuf::from("/src/c.ts"),
1014 commits: 7,
1015 weighted_commits: 5.0,
1016 lines_added: 100,
1017 lines_deleted: 30,
1018 trend: crate::churn::ChurnTrend::Cooling,
1019 authors: rustc_hash::FxHashMap::default(),
1020 },
1021 );
1022
1023 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1024 assert!((max_w - 8.5).abs() < f64::EPSILON);
1025 assert!((max_d - 1.2).abs() < f64::EPSILON);
1026 }
1027
1028 #[test]
1029 fn normalization_maxima_mixed_above_and_below_threshold() {
1030 let scores = vec![
1031 FileHealthScore {
1032 path: std::path::PathBuf::from("/src/frequent.ts"),
1033 fan_in: 0,
1034 fan_out: 0,
1035 dead_code_ratio: 0.0,
1036 complexity_density: 0.4,
1037 maintainability_index: 85.0,
1038 total_cyclomatic: 8,
1039 total_cognitive: 4,
1040 function_count: 2,
1041 lines: 40,
1042 crap_max: 0.0,
1043 crap_above_threshold: 0,
1044 crap_exempted: 0,
1045 crap_effective_threshold: None,
1046 },
1047 FileHealthScore {
1048 path: std::path::PathBuf::from("/src/rare.ts"),
1049 fan_in: 0,
1050 fan_out: 0,
1051 dead_code_ratio: 0.0,
1052 complexity_density: 2.0, maintainability_index: 50.0,
1054 total_cyclomatic: 40,
1055 total_cognitive: 30,
1056 function_count: 8,
1057 lines: 200,
1058 crap_max: 0.0,
1059 crap_above_threshold: 0,
1060 crap_exempted: 0,
1061 crap_effective_threshold: None,
1062 },
1063 ];
1064 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1065 rustc_hash::FxHashMap::default();
1066 churn_files.insert(
1067 std::path::PathBuf::from("/src/frequent.ts"),
1068 crate::churn::FileChurn {
1069 path: std::path::PathBuf::from("/src/frequent.ts"),
1070 commits: 10,
1071 weighted_commits: 7.0,
1072 lines_added: 150,
1073 lines_deleted: 40,
1074 trend: crate::churn::ChurnTrend::Stable,
1075 authors: rustc_hash::FxHashMap::default(),
1076 },
1077 );
1078 churn_files.insert(
1079 std::path::PathBuf::from("/src/rare.ts"),
1080 crate::churn::FileChurn {
1081 path: std::path::PathBuf::from("/src/rare.ts"),
1082 commits: 1, weighted_commits: 0.9,
1084 lines_added: 10,
1085 lines_deleted: 2,
1086 trend: crate::churn::ChurnTrend::Cooling,
1087 authors: rustc_hash::FxHashMap::default(),
1088 },
1089 );
1090
1091 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1092 assert!((max_w - 7.0).abs() < f64::EPSILON);
1093 assert!((max_d - 0.4).abs() < f64::EPSILON);
1094 }
1095
1096 #[test]
1097 fn normalization_maxima_file_score_without_churn() {
1098 let scores = vec![FileHealthScore {
1099 path: std::path::PathBuf::from("/src/no_churn.ts"),
1100 fan_in: 0,
1101 fan_out: 0,
1102 dead_code_ratio: 0.0,
1103 complexity_density: 5.0,
1104 maintainability_index: 30.0,
1105 total_cyclomatic: 100,
1106 total_cognitive: 80,
1107 function_count: 20,
1108 lines: 500,
1109 crap_max: 0.0,
1110 crap_above_threshold: 0,
1111 crap_exempted: 0,
1112 crap_effective_threshold: None,
1113 }];
1114 let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1115 rustc_hash::FxHashMap::default();
1116
1117 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1118 assert!((max_w).abs() < f64::EPSILON);
1119 assert!((max_d).abs() < f64::EPSILON);
1120 }
1121
1122 #[test]
1123 fn normalization_maxima_min_commits_zero() {
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: 0.3,
1130 maintainability_index: 90.0,
1131 total_cyclomatic: 3,
1132 total_cognitive: 2,
1133 function_count: 1,
1134 lines: 10,
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: 0,
1147 weighted_commits: 0.0,
1148 lines_added: 0,
1149 lines_deleted: 0,
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, 0);
1156 assert!((max_w).abs() < f64::EPSILON);
1157 assert!((max_d - 0.3).abs() < f64::EPSILON);
1158 }
1159
1160 #[test]
1161 fn normalization_maxima_exactly_at_threshold() {
1162 let scores = vec![FileHealthScore {
1163 path: std::path::PathBuf::from("/src/foo.ts"),
1164 fan_in: 0,
1165 fan_out: 0,
1166 dead_code_ratio: 0.0,
1167 complexity_density: 1.5,
1168 maintainability_index: 65.0,
1169 total_cyclomatic: 25,
1170 total_cognitive: 18,
1171 function_count: 5,
1172 lines: 120,
1173 crap_max: 0.0,
1174 crap_above_threshold: 0,
1175 crap_exempted: 0,
1176 crap_effective_threshold: None,
1177 }];
1178 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1179 rustc_hash::FxHashMap::default();
1180 churn_files.insert(
1181 std::path::PathBuf::from("/src/foo.ts"),
1182 crate::churn::FileChurn {
1183 path: std::path::PathBuf::from("/src/foo.ts"),
1184 commits: 3, weighted_commits: 2.8,
1186 lines_added: 60,
1187 lines_deleted: 15,
1188 trend: crate::churn::ChurnTrend::Stable,
1189 authors: rustc_hash::FxHashMap::default(),
1190 },
1191 );
1192
1193 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1194 assert!((max_w - 2.8).abs() < f64::EPSILON);
1195 assert!((max_d - 1.5).abs() < f64::EPSILON);
1196 }
1197
1198 #[test]
1199 fn excluded_workspace_and_glob_combined() {
1200 let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1201 let root = std::path::Path::new("/project");
1202 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1203 let mut builder = globset::GlobSetBuilder::new();
1204 builder.add(globset::Glob::new("**/generated/**").unwrap());
1205 let ignore_set = builder.build().unwrap();
1206
1207 assert!(is_excluded_from_hotspots(
1208 path,
1209 root,
1210 &ignore_set,
1211 Some(&ws_roots)
1212 ));
1213 }
1214
1215 #[test]
1216 fn excluded_workspace_match_but_glob_no_match() {
1217 let path = std::path::Path::new("/project/packages/a/src/index.ts");
1218 let root = std::path::Path::new("/project");
1219 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1220 let mut builder = globset::GlobSetBuilder::new();
1221 builder.add(globset::Glob::new("**/generated/**").unwrap());
1222 let ignore_set = builder.build().unwrap();
1223
1224 assert!(!is_excluded_from_hotspots(
1225 path,
1226 root,
1227 &ignore_set,
1228 Some(&ws_roots)
1229 ));
1230 }
1231
1232 #[test]
1233 fn excluded_path_equals_root() {
1234 let path = std::path::Path::new("/project");
1235 let root = std::path::Path::new("/project");
1236 let ignore_set = globset::GlobSet::empty();
1237
1238 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1239 }
1240
1241 #[test]
1242 fn excluded_path_outside_root() {
1243 let path = std::path::Path::new("/other/src/foo.ts");
1244 let root = std::path::Path::new("/project");
1245 let mut builder = globset::GlobSetBuilder::new();
1246 builder.add(globset::Glob::new("src/foo.ts").unwrap());
1247 let ignore_set = builder.build().unwrap();
1248
1249 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1250 }
1251
1252 #[test]
1253 fn excluded_multiple_globs_first_matches() {
1254 let path = std::path::Path::new("/project/dist/bundle.js");
1255 let root = std::path::Path::new("/project");
1256 let mut builder = globset::GlobSetBuilder::new();
1257 builder.add(globset::Glob::new("dist/**").unwrap());
1258 builder.add(globset::Glob::new("node_modules/**").unwrap());
1259 let ignore_set = builder.build().unwrap();
1260
1261 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1262 }
1263
1264 #[test]
1265 fn excluded_multiple_globs_second_matches() {
1266 let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1267 let root = std::path::Path::new("/project");
1268 let mut builder = globset::GlobSetBuilder::new();
1269 builder.add(globset::Glob::new("dist/**").unwrap());
1270 builder.add(globset::Glob::new("node_modules/**").unwrap());
1271 let ignore_set = builder.build().unwrap();
1272
1273 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1274 }
1275
1276 #[test]
1277 fn excluded_multiple_globs_none_matches() {
1278 let path = std::path::Path::new("/project/src/app.ts");
1279 let root = std::path::Path::new("/project");
1280 let mut builder = globset::GlobSetBuilder::new();
1281 builder.add(globset::Glob::new("dist/**").unwrap());
1282 builder.add(globset::Glob::new("node_modules/**").unwrap());
1283 let ignore_set = builder.build().unwrap();
1284
1285 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1286 }
1287}