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