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