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