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 super::diagnostics::record_health_diagnostic(
190 opts.root,
191 Some(&resolved),
192 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
193 cause: "churn-file-unreadable".to_owned(),
194 },
195 );
196 return None;
197 }
198 };
199 return Some(ChurnFetchResult {
200 result,
201 since: imported_since(),
202 cache_hit: false,
203 git_log_ms: t.elapsed().as_secs_f64() * 1000.0,
204 });
205 }
206
207 if !crate::churn::is_git_repo(opts.root) {
208 if !opts.quiet {
209 eprintln!("note: hotspot analysis skipped: no git repository found at project root");
210 }
211 super::diagnostics::record_health_diagnostic(
214 opts.root,
215 None,
216 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
217 cause: "not-a-repository".to_owned(),
218 },
219 );
220 return None;
221 }
222
223 let since_input = opts.since.unwrap_or("6m");
224 if let Err(e) = crate::validate::validate_no_control_chars(since_input, "--since") {
225 tracing::warn!("hotspot analysis skipped: {e}");
232 record_invalid_since(opts.root);
233 return None;
234 }
235 let since = match crate::churn::parse_since(since_input) {
236 Ok(s) => s,
237 Err(e) => {
238 tracing::warn!("hotspot analysis skipped: invalid --since: {e}");
239 record_invalid_since(opts.root);
240 return None;
241 }
242 };
243
244 let t = std::time::Instant::now();
245 let Some((churn_result, cache_hit)) =
246 crate::churn::analyze_churn_cached(opts.root, &since, cache_dir, opts.no_cache)
247 else {
248 record_unborn_head(opts);
249 return None;
250 };
251 let git_log_ms = t.elapsed().as_secs_f64() * 1000.0;
252
253 Some(ChurnFetchResult {
254 result: churn_result,
255 since,
256 cache_hit,
257 git_log_ms,
258 })
259}
260
261fn record_unborn_head(opts: &HealthOptions<'_>) {
267 if !matches!(crate::repo_refs::head_sha(opts.root), Ok(None)) {
268 return;
269 }
270 if !opts.quiet {
271 eprintln!("note: hotspot analysis skipped: the current branch has no commits yet");
272 }
273 super::diagnostics::record_health_diagnostic(
274 opts.root,
275 None,
276 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
277 cause: "no-commits".to_owned(),
278 },
279 );
280}
281
282fn record_invalid_since(root: &std::path::Path) {
289 super::diagnostics::record_health_diagnostic(
290 root,
291 None,
292 fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
293 cause: "invalid-since".to_owned(),
294 },
295 );
296}
297
298fn imported_since() -> crate::churn::SinceDuration {
302 crate::churn::SinceDuration {
303 window: crate::churn::ChurnWindow::Imported,
304 display: "imported churn".to_string(),
305 }
306}
307
308fn compute_normalization_maxima(
312 file_scores: &[FileHealthScore],
313 churn_files: &rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
314 min_commits: u32,
315) -> (f64, f64) {
316 let mut max_weighted: f64 = 0.0;
317 let mut max_density: f64 = 0.0;
318 for score in file_scores {
319 if let Some(churn) = churn_files.get(&score.path)
320 && churn.commits >= min_commits
321 {
322 max_weighted = max_weighted.max(churn.weighted_commits);
323 max_density = max_density.max(score.complexity_density);
324 }
325 }
326 (max_weighted, max_density)
327}
328
329fn is_excluded_from_hotspots(
332 path: &std::path::Path,
333 root: &std::path::Path,
334 ignore_set: &globset::GlobSet,
335 ws_roots: Option<&[std::path::PathBuf]>,
336) -> bool {
337 if let Some(ws) = ws_roots
338 && !ws.iter().any(|r| path.starts_with(r))
339 {
340 return true;
341 }
342 if !ignore_set.is_empty() {
343 let relative = path.strip_prefix(root).unwrap_or(path);
344 if ignore_set.is_match(relative) {
345 return true;
346 }
347 }
348 false
349}
350
351fn compute_hotspot_score(
356 weighted_commits: f64,
357 max_weighted: f64,
358 complexity_density: f64,
359 max_density: f64,
360) -> f64 {
361 let norm_churn = if max_weighted > 0.0 {
362 weighted_commits / max_weighted
363 } else {
364 0.0
365 };
366 let norm_complexity = if max_density > 0.0 {
367 complexity_density / max_density
368 } else {
369 0.0
370 };
371 (norm_churn * norm_complexity * 100.0 * 10.0).round() / 10.0
372}
373
374pub(super) struct HotspotComputationInput<'a> {
375 pub(super) opts: &'a HealthOptions<'a>,
376 pub(super) config: &'a fallow_config::ResolvedConfig,
377 pub(super) file_scores: &'a [FileHealthScore],
378 pub(super) ignore_set: &'a globset::GlobSet,
379 pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
380 pub(super) churn_fetch: ChurnFetchResult,
381}
382
383pub(super) fn compute_hotspots(
385 input: HotspotComputationInput<'_>,
386) -> (Vec<HotspotEntry>, Option<HotspotSummary>) {
387 let HotspotComputationInput {
388 opts,
389 config,
390 file_scores,
391 ignore_set,
392 ws_roots,
393 churn_fetch,
394 } = input;
395 let churn_result = churn_fetch.result;
396 let since = churn_fetch.since;
397
398 let shallow_clone = churn_result.shallow_clone;
399 warn_shallow_clone(opts, shallow_clone);
400 warn_unpinned_clock(opts, churn_result.clock);
401
402 let min_commits = opts.min_commits.unwrap_or(3);
403 let (max_weighted, max_density) =
404 compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
405
406 let ownership_cfg = &config.health.ownership;
407 let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
408 let codeowners_owned = load_ownership_codeowners(opts, &config.root);
409 let now_secs = churn_result.clock.epoch_secs();
413 let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
414 author_pool: &churn_result.author_pool,
415 bot_globs,
416 codeowners: codeowners_owned.as_ref(),
417 email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
418 now_secs,
419 });
420
421 let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
422 file_scores,
423 root: &config.root,
424 ignore_set,
425 ws_roots,
426 churn_files: &churn_result.files,
427 min_commits,
428 max_weighted,
429 max_density,
430 ownership_ctx: ownership_ctx.as_ref(),
431 });
432
433 hotspot_entries.sort_by(|a, b| {
434 b.score
435 .partial_cmp(&a.score)
436 .unwrap_or(std::cmp::Ordering::Equal)
437 });
438
439 let files_analyzed = hotspot_entries.len();
440 let summary = HotspotSummary {
441 since: since.display,
442 min_commits,
443 files_analyzed,
444 files_excluded,
445 shallow_clone,
446 clock: Some(clock_provenance(churn_result.clock)),
447 };
448
449 if let Some(top) = opts.top {
450 hotspot_entries.truncate(top);
451 }
452
453 (hotspot_entries, Some(summary))
454}
455
456fn clock_provenance(clock: crate::clock::AnalysisClock) -> ClockProvenance {
462 ClockProvenance {
463 source: match clock.source() {
464 crate::clock::AnalysisClockSource::Environment => ClockSource::Environment,
465 crate::clock::AnalysisClockSource::HeadCommit => ClockSource::HeadCommit,
466 crate::clock::AnalysisClockSource::WallClock => ClockSource::WallClock,
467 },
468 epoch_secs: clock.epoch_secs(),
469 reproducible: clock.is_reproducible(),
470 }
471}
472
473fn warn_unpinned_clock(opts: &HealthOptions<'_>, clock: crate::clock::AnalysisClock) {
482 if clock.is_reproducible() {
483 return;
484 }
485 if !opts.quiet {
486 eprintln!(
487 "Warning: no commit timestamp available, so churn recency and \
488 ownership staleness were measured against the wall clock and will \
489 drift between runs. Set FALLOW_CLOCK_EPOCH to pin them."
490 );
491 }
492 super::diagnostics::record_health_diagnostic(
493 opts.root,
494 None,
495 fallow_types::workspace::WorkspaceDiagnosticKind::UnpinnedClock,
496 );
497}
498
499fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
501 if !shallow_clone {
502 return;
503 }
504 if !opts.quiet {
505 eprintln!(
506 "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
507 Use `git fetch --unshallow` for full history."
508 );
509 if opts.ownership {
510 eprintln!(
511 "Warning: shallow clones inflate single-author dominance, so \
512 ownership signals will be skewed."
513 );
514 }
515 }
516 super::diagnostics::record_health_diagnostic(
517 opts.root,
518 None,
519 fallow_types::workspace::WorkspaceDiagnosticKind::ShallowClone {
520 ownership_requested: opts.ownership,
521 },
522 );
523}
524
525fn load_ownership_bot_globs(
527 opts: &HealthOptions<'_>,
528 ownership_cfg: &fallow_config::OwnershipConfig,
529) -> Option<globset::GlobSet> {
530 opts.ownership.then(|| {
531 compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
532 if !opts.quiet {
533 eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
534 }
535 super::diagnostics::record_health_diagnostic(
536 opts.root,
537 None,
538 fallow_types::workspace::WorkspaceDiagnosticKind::OwnershipUnavailable {
539 cause: "invalid-bot-pattern".to_owned(),
540 error: e.to_string(),
541 },
542 );
543 globset::GlobSet::empty()
544 })
545 })
546}
547
548fn load_ownership_codeowners(
550 opts: &HealthOptions<'_>,
551 root: &std::path::Path,
552) -> Option<crate::codeowners::CodeOwners> {
553 opts.ownership
554 .then(|| match crate::codeowners::CodeOwners::load(root, None) {
555 Ok(co) => Some(co),
556 Err(e) => {
557 if !e.contains("no CODEOWNERS file found") {
561 if !opts.quiet {
562 eprintln!("Warning: failed to parse CODEOWNERS: {e}");
563 }
564 super::diagnostics::record_health_diagnostic(
565 root,
566 None,
567 fallow_types::workspace::WorkspaceDiagnosticKind::OwnershipUnavailable {
568 cause: "codeowners-parse-failed".to_owned(),
569 error: e,
570 },
571 );
572 }
573 None
574 }
575 })
576 .flatten()
577}
578
579struct HotspotEntryCtx<'a> {
581 file_scores: &'a [FileHealthScore],
582 root: &'a std::path::Path,
583 ignore_set: &'a globset::GlobSet,
584 ws_roots: Option<&'a [std::path::PathBuf]>,
585 churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
586 min_commits: u32,
587 max_weighted: f64,
588 max_density: f64,
589 ownership_ctx: Option<&'a OwnershipContext<'a>>,
590}
591
592fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
595 let mut hotspot_entries = Vec::new();
596 let mut files_excluded: usize = 0;
597
598 for score in ctx.file_scores {
599 if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
600 continue;
601 }
602
603 let Some(churn) = ctx.churn_files.get(&score.path) else {
604 continue;
605 };
606 if churn.commits < ctx.min_commits {
607 files_excluded += 1;
608 continue;
609 }
610
611 let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
612 let ownership = ctx
613 .ownership_ctx
614 .and_then(|own| compute_ownership(churn, relative, own));
615
616 hotspot_entries.push(HotspotEntry {
617 path: score.path.clone(),
618 score: compute_hotspot_score(
619 churn.weighted_commits,
620 ctx.max_weighted,
621 score.complexity_density,
622 ctx.max_density,
623 ),
624 commits: churn.commits,
625 weighted_commits: churn.weighted_commits,
626 lines_added: churn.lines_added,
627 lines_deleted: churn.lines_deleted,
628 complexity_density: score.complexity_density,
629 fan_in: score.fan_in,
630 trend: churn.trend,
631 ownership,
632 is_test_path: is_test_path(relative),
633 });
634 }
635
636 (hotspot_entries, files_excluded)
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
644 TargetChurnOptions {
645 root,
646 target: std::path::Path::new("src/app.ts"),
647 cache_dir: root.join(".fallow"),
648 no_cache: true,
649 since: None,
650 min_commits: None,
651 }
652 }
653
654 fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
655 let path = root.join("src/app.ts");
656 let mut files = rustc_hash::FxHashMap::default();
657 files.insert(
658 path.clone(),
659 crate::churn::FileChurn {
660 path,
661 commits,
662 weighted_commits: 2.5,
663 lines_added: 20,
664 lines_deleted: 5,
665 trend: crate::churn::ChurnTrend::Accelerating,
666 authors: rustc_hash::FxHashMap::default(),
667 },
668 );
669 crate::churn::ChurnResult {
670 files,
671 shallow_clone: false,
672 author_pool: Vec::new(),
673 clock: crate::clock::AnalysisClock::pinned(1_788_782_400),
674 }
675 }
676
677 #[test]
678 fn target_churn_returns_only_the_requested_qualifying_file() {
679 let root = std::path::Path::new("/project");
680 let options = target_churn_options(root);
681
682 let outcome = analyze_target_churn_with(
683 &options,
684 |_| true,
685 |_, _, _, _| Some((churn_result(root, 4), false)),
686 )
687 .unwrap();
688
689 let TargetChurnOutcome::Found(evidence) = outcome else {
690 panic!("expected qualifying churn evidence");
691 };
692 assert_eq!(evidence.file.path, root.join("src/app.ts"));
693 assert_eq!(evidence.file.commits, 4);
694 assert_eq!(evidence.min_commits, 3);
695 assert_eq!(evidence.since.display, "6 months");
696 }
697
698 #[test]
699 fn target_churn_distinguishes_no_qualifying_history() {
700 let root = std::path::Path::new("/project");
701 let options = target_churn_options(root);
702
703 let outcome = analyze_target_churn_with(
704 &options,
705 |_| true,
706 |_, _, _, _| Some((churn_result(root, 2), false)),
707 )
708 .unwrap();
709
710 assert!(matches!(
711 outcome,
712 TargetChurnOutcome::NoQualifyingChurn {
713 observed_commits: Some(2),
714 min_commits: 3,
715 ..
716 }
717 ));
718 }
719
720 #[test]
721 fn target_churn_distinguishes_git_unavailable() {
722 let root = std::path::Path::new("/project");
723 let options = target_churn_options(root);
724
725 let outcome = analyze_target_churn_with(
726 &options,
727 |_| false,
728 |_, _, _, _| panic!("churn analysis must not run without git"),
729 )
730 .unwrap();
731
732 assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
733 }
734
735 #[test]
736 fn target_churn_surfaces_analysis_failure() {
737 let root = std::path::Path::new("/project");
738 let options = target_churn_options(root);
739
740 let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
741 .expect_err("failed git analysis must remain explicit");
742
743 assert!(error.contains("git churn analysis failed"));
744 }
745
746 #[test]
747 fn hotspot_score_both_maxima_zero() {
748 assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
749 }
750
751 #[test]
752 fn hotspot_score_max_weighted_zero() {
753 assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
754 }
755
756 #[test]
757 fn hotspot_score_max_density_zero() {
758 assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
759 }
760
761 #[test]
762 fn hotspot_score_equal_normalization() {
763 let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
764 assert!((score - 100.0).abs() < f64::EPSILON);
765 }
766
767 #[test]
768 fn hotspot_score_half_values() {
769 let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
770 assert!((score - 25.0).abs() < f64::EPSILON);
771 }
772
773 #[test]
774 fn excluded_no_filters() {
775 let path = std::path::Path::new("/project/src/foo.ts");
776 let root = std::path::Path::new("/project");
777 let ignore_set = globset::GlobSet::empty();
778
779 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
780 }
781
782 #[test]
783 fn excluded_workspace_filter_mismatch() {
784 let path = std::path::Path::new("/project/packages/b/src/foo.ts");
785 let root = std::path::Path::new("/project");
786 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
787 let ignore_set = globset::GlobSet::empty();
788
789 assert!(is_excluded_from_hotspots(
790 path,
791 root,
792 &ignore_set,
793 Some(&ws_roots)
794 ));
795 }
796
797 #[test]
798 fn excluded_workspace_filter_match() {
799 let path = std::path::Path::new("/project/packages/a/src/foo.ts");
800 let root = std::path::Path::new("/project");
801 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
802 let ignore_set = globset::GlobSet::empty();
803
804 assert!(!is_excluded_from_hotspots(
805 path,
806 root,
807 &ignore_set,
808 Some(&ws_roots)
809 ));
810 }
811
812 #[test]
813 fn excluded_matching_glob() {
814 let path = std::path::Path::new("/project/src/generated/types.ts");
815 let root = std::path::Path::new("/project");
816 let mut builder = globset::GlobSetBuilder::new();
817 builder.add(globset::Glob::new("src/generated/**").unwrap());
818 let ignore_set = builder.build().unwrap();
819
820 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
821 }
822
823 #[test]
824 fn excluded_non_matching_glob() {
825 let path = std::path::Path::new("/project/src/components/Button.tsx");
826 let root = std::path::Path::new("/project");
827 let mut builder = globset::GlobSetBuilder::new();
828 builder.add(globset::Glob::new("src/generated/**").unwrap());
829 let ignore_set = builder.build().unwrap();
830
831 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
832 }
833
834 #[test]
835 fn normalization_maxima_empty_input() {
836 let scores: Vec<FileHealthScore> = vec![];
837 let churn_files = rustc_hash::FxHashMap::default();
838
839 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
840 assert!((max_w).abs() < f64::EPSILON);
841 assert!((max_d).abs() < f64::EPSILON);
842 }
843
844 #[test]
845 fn normalization_maxima_single_file() {
846 let scores = vec![FileHealthScore {
847 path: std::path::PathBuf::from("/src/foo.ts"),
848 fan_in: 0,
849 fan_out: 0,
850 dead_code_ratio: 0.0,
851 complexity_density: 0.75,
852 maintainability_index: 80.0,
853 total_cyclomatic: 15,
854 total_cognitive: 10,
855 function_count: 3,
856 lines: 20,
857 crap_max: 0.0,
858 crap_above_threshold: 0,
859 crap_exempted: 0,
860 crap_effective_threshold: None,
861 }];
862 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
863 rustc_hash::FxHashMap::default();
864 churn_files.insert(
865 std::path::PathBuf::from("/src/foo.ts"),
866 crate::churn::FileChurn {
867 path: std::path::PathBuf::from("/src/foo.ts"),
868 commits: 5,
869 weighted_commits: 4.2,
870 lines_added: 100,
871 lines_deleted: 20,
872 trend: crate::churn::ChurnTrend::Stable,
873 authors: rustc_hash::FxHashMap::default(),
874 },
875 );
876
877 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
878 assert!((max_w - 4.2).abs() < f64::EPSILON);
879 assert!((max_d - 0.75).abs() < f64::EPSILON);
880 }
881
882 #[test]
886 fn normalization_maxima_ignore_crap_exemption_fields() {
887 let base = FileHealthScore {
888 path: std::path::PathBuf::from("/src/foo.ts"),
889 fan_in: 0,
890 fan_out: 0,
891 dead_code_ratio: 0.0,
892 complexity_density: 0.75,
893 maintainability_index: 80.0,
894 total_cyclomatic: 15,
895 total_cognitive: 10,
896 function_count: 3,
897 lines: 20,
898 crap_max: 110.0,
899 crap_above_threshold: 2,
900 crap_exempted: 0,
901 crap_effective_threshold: None,
902 };
903 let exempt = FileHealthScore {
904 crap_above_threshold: 0,
905 crap_exempted: 2,
906 crap_effective_threshold: Some(500.0),
907 ..base.clone()
908 };
909 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
910 rustc_hash::FxHashMap::default();
911 churn_files.insert(
912 std::path::PathBuf::from("/src/foo.ts"),
913 crate::churn::FileChurn {
914 path: std::path::PathBuf::from("/src/foo.ts"),
915 commits: 5,
916 weighted_commits: 4.2,
917 lines_added: 100,
918 lines_deleted: 20,
919 trend: crate::churn::ChurnTrend::Stable,
920 authors: rustc_hash::FxHashMap::default(),
921 },
922 );
923
924 let flagged = compute_normalization_maxima(&[base], &churn_files, 3);
925 let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
926 assert_eq!(flagged, exempted);
927 }
928
929 #[test]
930 fn normalization_maxima_below_min_commits() {
931 let scores = vec![FileHealthScore {
932 path: std::path::PathBuf::from("/src/foo.ts"),
933 fan_in: 0,
934 fan_out: 0,
935 dead_code_ratio: 0.0,
936 complexity_density: 0.75,
937 maintainability_index: 80.0,
938 total_cyclomatic: 15,
939 total_cognitive: 10,
940 function_count: 3,
941 lines: 20,
942 crap_max: 0.0,
943 crap_above_threshold: 0,
944 crap_exempted: 0,
945 crap_effective_threshold: None,
946 }];
947 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
948 rustc_hash::FxHashMap::default();
949 churn_files.insert(
950 std::path::PathBuf::from("/src/foo.ts"),
951 crate::churn::FileChurn {
952 path: std::path::PathBuf::from("/src/foo.ts"),
953 commits: 2, weighted_commits: 4.2,
955 lines_added: 100,
956 lines_deleted: 20,
957 trend: crate::churn::ChurnTrend::Stable,
958 authors: rustc_hash::FxHashMap::default(),
959 },
960 );
961
962 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
963 assert!((max_w).abs() < f64::EPSILON);
964 assert!((max_d).abs() < f64::EPSILON);
965 }
966
967 #[test]
968 fn normalization_maxima_all_zeros() {
969 let scores = vec![FileHealthScore {
970 path: std::path::PathBuf::from("/src/foo.ts"),
971 fan_in: 0,
972 fan_out: 0,
973 dead_code_ratio: 0.0,
974 complexity_density: 0.0,
975 maintainability_index: 100.0,
976 total_cyclomatic: 0,
977 total_cognitive: 0,
978 function_count: 1,
979 lines: 10,
980 crap_max: 0.0,
981 crap_above_threshold: 0,
982 crap_exempted: 0,
983 crap_effective_threshold: None,
984 }];
985 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
986 rustc_hash::FxHashMap::default();
987 churn_files.insert(
988 std::path::PathBuf::from("/src/foo.ts"),
989 crate::churn::FileChurn {
990 path: std::path::PathBuf::from("/src/foo.ts"),
991 commits: 5,
992 weighted_commits: 0.0,
993 lines_added: 0,
994 lines_deleted: 0,
995 trend: crate::churn::ChurnTrend::Stable,
996 authors: rustc_hash::FxHashMap::default(),
997 },
998 );
999
1000 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1001 assert!((max_w).abs() < f64::EPSILON);
1002 assert!((max_d).abs() < f64::EPSILON);
1003 }
1004
1005 #[test]
1006 fn hotspot_score_high_churn_low_complexity() {
1007 let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
1008 assert!((score - 10.0).abs() < f64::EPSILON);
1009 }
1010
1011 #[test]
1012 fn hotspot_score_low_churn_high_complexity() {
1013 let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
1014 assert!((score - 10.0).abs() < f64::EPSILON);
1015 }
1016
1017 #[test]
1018 fn hotspot_score_rounding() {
1019 let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
1020 assert!((score - 11.1).abs() < f64::EPSILON);
1021 }
1022
1023 #[test]
1024 fn hotspot_score_very_small_values() {
1025 let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
1026 assert!((score).abs() < 0.1);
1027 }
1028
1029 #[test]
1030 fn hotspot_score_weighted_exceeds_max() {
1031 let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
1032 assert!((score - 75.0).abs() < f64::EPSILON);
1033 }
1034
1035 #[test]
1036 fn normalization_maxima_multiple_files_picks_max() {
1037 let scores = vec![
1038 FileHealthScore {
1039 path: std::path::PathBuf::from("/src/a.ts"),
1040 fan_in: 0,
1041 fan_out: 0,
1042 dead_code_ratio: 0.0,
1043 complexity_density: 0.5,
1044 maintainability_index: 80.0,
1045 total_cyclomatic: 10,
1046 total_cognitive: 5,
1047 function_count: 2,
1048 lines: 50,
1049 crap_max: 0.0,
1050 crap_above_threshold: 0,
1051 crap_exempted: 0,
1052 crap_effective_threshold: None,
1053 },
1054 FileHealthScore {
1055 path: std::path::PathBuf::from("/src/b.ts"),
1056 fan_in: 0,
1057 fan_out: 0,
1058 dead_code_ratio: 0.0,
1059 complexity_density: 1.2, maintainability_index: 60.0,
1061 total_cyclomatic: 30,
1062 total_cognitive: 20,
1063 function_count: 5,
1064 lines: 100,
1065 crap_max: 0.0,
1066 crap_above_threshold: 0,
1067 crap_exempted: 0,
1068 crap_effective_threshold: None,
1069 },
1070 FileHealthScore {
1071 path: std::path::PathBuf::from("/src/c.ts"),
1072 fan_in: 0,
1073 fan_out: 0,
1074 dead_code_ratio: 0.0,
1075 complexity_density: 0.8,
1076 maintainability_index: 70.0,
1077 total_cyclomatic: 20,
1078 total_cognitive: 15,
1079 function_count: 4,
1080 lines: 80,
1081 crap_max: 0.0,
1082 crap_above_threshold: 0,
1083 crap_exempted: 0,
1084 crap_effective_threshold: None,
1085 },
1086 ];
1087 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1088 rustc_hash::FxHashMap::default();
1089 churn_files.insert(
1090 std::path::PathBuf::from("/src/a.ts"),
1091 crate::churn::FileChurn {
1092 path: std::path::PathBuf::from("/src/a.ts"),
1093 commits: 5,
1094 weighted_commits: 3.0,
1095 lines_added: 50,
1096 lines_deleted: 10,
1097 trend: crate::churn::ChurnTrend::Stable,
1098 authors: rustc_hash::FxHashMap::default(),
1099 },
1100 );
1101 churn_files.insert(
1102 std::path::PathBuf::from("/src/b.ts"),
1103 crate::churn::FileChurn {
1104 path: std::path::PathBuf::from("/src/b.ts"),
1105 commits: 10,
1106 weighted_commits: 8.5, lines_added: 200,
1108 lines_deleted: 50,
1109 trend: crate::churn::ChurnTrend::Accelerating,
1110 authors: rustc_hash::FxHashMap::default(),
1111 },
1112 );
1113 churn_files.insert(
1114 std::path::PathBuf::from("/src/c.ts"),
1115 crate::churn::FileChurn {
1116 path: std::path::PathBuf::from("/src/c.ts"),
1117 commits: 7,
1118 weighted_commits: 5.0,
1119 lines_added: 100,
1120 lines_deleted: 30,
1121 trend: crate::churn::ChurnTrend::Cooling,
1122 authors: rustc_hash::FxHashMap::default(),
1123 },
1124 );
1125
1126 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1127 assert!((max_w - 8.5).abs() < f64::EPSILON);
1128 assert!((max_d - 1.2).abs() < f64::EPSILON);
1129 }
1130
1131 #[test]
1132 fn normalization_maxima_mixed_above_and_below_threshold() {
1133 let scores = vec![
1134 FileHealthScore {
1135 path: std::path::PathBuf::from("/src/frequent.ts"),
1136 fan_in: 0,
1137 fan_out: 0,
1138 dead_code_ratio: 0.0,
1139 complexity_density: 0.4,
1140 maintainability_index: 85.0,
1141 total_cyclomatic: 8,
1142 total_cognitive: 4,
1143 function_count: 2,
1144 lines: 40,
1145 crap_max: 0.0,
1146 crap_above_threshold: 0,
1147 crap_exempted: 0,
1148 crap_effective_threshold: None,
1149 },
1150 FileHealthScore {
1151 path: std::path::PathBuf::from("/src/rare.ts"),
1152 fan_in: 0,
1153 fan_out: 0,
1154 dead_code_ratio: 0.0,
1155 complexity_density: 2.0, maintainability_index: 50.0,
1157 total_cyclomatic: 40,
1158 total_cognitive: 30,
1159 function_count: 8,
1160 lines: 200,
1161 crap_max: 0.0,
1162 crap_above_threshold: 0,
1163 crap_exempted: 0,
1164 crap_effective_threshold: None,
1165 },
1166 ];
1167 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1168 rustc_hash::FxHashMap::default();
1169 churn_files.insert(
1170 std::path::PathBuf::from("/src/frequent.ts"),
1171 crate::churn::FileChurn {
1172 path: std::path::PathBuf::from("/src/frequent.ts"),
1173 commits: 10,
1174 weighted_commits: 7.0,
1175 lines_added: 150,
1176 lines_deleted: 40,
1177 trend: crate::churn::ChurnTrend::Stable,
1178 authors: rustc_hash::FxHashMap::default(),
1179 },
1180 );
1181 churn_files.insert(
1182 std::path::PathBuf::from("/src/rare.ts"),
1183 crate::churn::FileChurn {
1184 path: std::path::PathBuf::from("/src/rare.ts"),
1185 commits: 1, weighted_commits: 0.9,
1187 lines_added: 10,
1188 lines_deleted: 2,
1189 trend: crate::churn::ChurnTrend::Cooling,
1190 authors: rustc_hash::FxHashMap::default(),
1191 },
1192 );
1193
1194 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1195 assert!((max_w - 7.0).abs() < f64::EPSILON);
1196 assert!((max_d - 0.4).abs() < f64::EPSILON);
1197 }
1198
1199 #[test]
1200 fn normalization_maxima_file_score_without_churn() {
1201 let scores = vec![FileHealthScore {
1202 path: std::path::PathBuf::from("/src/no_churn.ts"),
1203 fan_in: 0,
1204 fan_out: 0,
1205 dead_code_ratio: 0.0,
1206 complexity_density: 5.0,
1207 maintainability_index: 30.0,
1208 total_cyclomatic: 100,
1209 total_cognitive: 80,
1210 function_count: 20,
1211 lines: 500,
1212 crap_max: 0.0,
1213 crap_above_threshold: 0,
1214 crap_exempted: 0,
1215 crap_effective_threshold: None,
1216 }];
1217 let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1218 rustc_hash::FxHashMap::default();
1219
1220 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1221 assert!((max_w).abs() < f64::EPSILON);
1222 assert!((max_d).abs() < f64::EPSILON);
1223 }
1224
1225 #[test]
1226 fn normalization_maxima_min_commits_zero() {
1227 let scores = vec![FileHealthScore {
1228 path: std::path::PathBuf::from("/src/foo.ts"),
1229 fan_in: 0,
1230 fan_out: 0,
1231 dead_code_ratio: 0.0,
1232 complexity_density: 0.3,
1233 maintainability_index: 90.0,
1234 total_cyclomatic: 3,
1235 total_cognitive: 2,
1236 function_count: 1,
1237 lines: 10,
1238 crap_max: 0.0,
1239 crap_above_threshold: 0,
1240 crap_exempted: 0,
1241 crap_effective_threshold: None,
1242 }];
1243 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1244 rustc_hash::FxHashMap::default();
1245 churn_files.insert(
1246 std::path::PathBuf::from("/src/foo.ts"),
1247 crate::churn::FileChurn {
1248 path: std::path::PathBuf::from("/src/foo.ts"),
1249 commits: 0,
1250 weighted_commits: 0.0,
1251 lines_added: 0,
1252 lines_deleted: 0,
1253 trend: crate::churn::ChurnTrend::Stable,
1254 authors: rustc_hash::FxHashMap::default(),
1255 },
1256 );
1257
1258 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1259 assert!((max_w).abs() < f64::EPSILON);
1260 assert!((max_d - 0.3).abs() < f64::EPSILON);
1261 }
1262
1263 #[test]
1264 fn normalization_maxima_exactly_at_threshold() {
1265 let scores = vec![FileHealthScore {
1266 path: std::path::PathBuf::from("/src/foo.ts"),
1267 fan_in: 0,
1268 fan_out: 0,
1269 dead_code_ratio: 0.0,
1270 complexity_density: 1.5,
1271 maintainability_index: 65.0,
1272 total_cyclomatic: 25,
1273 total_cognitive: 18,
1274 function_count: 5,
1275 lines: 120,
1276 crap_max: 0.0,
1277 crap_above_threshold: 0,
1278 crap_exempted: 0,
1279 crap_effective_threshold: None,
1280 }];
1281 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1282 rustc_hash::FxHashMap::default();
1283 churn_files.insert(
1284 std::path::PathBuf::from("/src/foo.ts"),
1285 crate::churn::FileChurn {
1286 path: std::path::PathBuf::from("/src/foo.ts"),
1287 commits: 3, weighted_commits: 2.8,
1289 lines_added: 60,
1290 lines_deleted: 15,
1291 trend: crate::churn::ChurnTrend::Stable,
1292 authors: rustc_hash::FxHashMap::default(),
1293 },
1294 );
1295
1296 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1297 assert!((max_w - 2.8).abs() < f64::EPSILON);
1298 assert!((max_d - 1.5).abs() < f64::EPSILON);
1299 }
1300
1301 #[test]
1302 fn excluded_workspace_and_glob_combined() {
1303 let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1304 let root = std::path::Path::new("/project");
1305 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1306 let mut builder = globset::GlobSetBuilder::new();
1307 builder.add(globset::Glob::new("**/generated/**").unwrap());
1308 let ignore_set = builder.build().unwrap();
1309
1310 assert!(is_excluded_from_hotspots(
1311 path,
1312 root,
1313 &ignore_set,
1314 Some(&ws_roots)
1315 ));
1316 }
1317
1318 #[test]
1319 fn excluded_workspace_match_but_glob_no_match() {
1320 let path = std::path::Path::new("/project/packages/a/src/index.ts");
1321 let root = std::path::Path::new("/project");
1322 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1323 let mut builder = globset::GlobSetBuilder::new();
1324 builder.add(globset::Glob::new("**/generated/**").unwrap());
1325 let ignore_set = builder.build().unwrap();
1326
1327 assert!(!is_excluded_from_hotspots(
1328 path,
1329 root,
1330 &ignore_set,
1331 Some(&ws_roots)
1332 ));
1333 }
1334
1335 #[test]
1336 fn excluded_path_equals_root() {
1337 let path = std::path::Path::new("/project");
1338 let root = std::path::Path::new("/project");
1339 let ignore_set = globset::GlobSet::empty();
1340
1341 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1342 }
1343
1344 #[test]
1345 fn excluded_path_outside_root() {
1346 let path = std::path::Path::new("/other/src/foo.ts");
1347 let root = std::path::Path::new("/project");
1348 let mut builder = globset::GlobSetBuilder::new();
1349 builder.add(globset::Glob::new("src/foo.ts").unwrap());
1350 let ignore_set = builder.build().unwrap();
1351
1352 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1353 }
1354
1355 #[test]
1356 fn excluded_multiple_globs_first_matches() {
1357 let path = std::path::Path::new("/project/dist/bundle.js");
1358 let root = std::path::Path::new("/project");
1359 let mut builder = globset::GlobSetBuilder::new();
1360 builder.add(globset::Glob::new("dist/**").unwrap());
1361 builder.add(globset::Glob::new("node_modules/**").unwrap());
1362 let ignore_set = builder.build().unwrap();
1363
1364 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1365 }
1366
1367 #[test]
1368 fn excluded_multiple_globs_second_matches() {
1369 let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1370 let root = std::path::Path::new("/project");
1371 let mut builder = globset::GlobSetBuilder::new();
1372 builder.add(globset::Glob::new("dist/**").unwrap());
1373 builder.add(globset::Glob::new("node_modules/**").unwrap());
1374 let ignore_set = builder.build().unwrap();
1375
1376 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1377 }
1378
1379 #[test]
1380 fn excluded_multiple_globs_none_matches() {
1381 let path = std::path::Path::new("/project/src/app.ts");
1382 let root = std::path::Path::new("/project");
1383 let mut builder = globset::GlobSetBuilder::new();
1384 builder.add(globset::Glob::new("dist/**").unwrap());
1385 builder.add(globset::Glob::new("node_modules/**").unwrap());
1386 let ignore_set = builder.build().unwrap();
1387
1388 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1389 }
1390}