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