Skip to main content

fallow_engine/health/
hotspots.rs

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::{FileHealthScore, HotspotEntry, HotspotSummary};
7
8use super::HealthOptions;
9use super::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
10
11/// Detect test/mock path conventions. Kept as a simple substring scan
12/// against forward-slash normalized paths so it works uniformly on the
13/// relative paths we use for display.
14fn 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
24/// Result of fetching churn data, including cache hit/miss info and timing.
25pub 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
32/// Focused inputs for target-level churn evidence.
33///
34/// This reuses the health hotspot churn cache without running project parsing,
35/// file scoring, or any other health section.
36pub struct TargetChurnOptions<'a> {
37    /// Project root containing the git repository.
38    pub root: &'a std::path::Path,
39    /// Normalized project-relative path of the file to fetch churn for.
40    pub target: &'a std::path::Path,
41    /// Directory holding the shared hotspot churn cache.
42    pub cache_dir: std::path::PathBuf,
43    /// Bypass the churn cache and re-run `git log`.
44    pub no_cache: bool,
45    /// Churn lookback window (`90d`, `6m`, `1y`, or an ISO date); `None` uses
46    /// the default window.
47    pub since: Option<&'a str>,
48    /// Minimum commit count for the target to qualify as churn evidence.
49    pub min_commits: Option<u32>,
50}
51
52/// Qualifying target-level churn returned by the focused health API.
53#[derive(Debug)]
54pub struct TargetChurnEvidence {
55    /// Per-file churn metrics from git history.
56    pub file: crate::churn::FileChurn,
57    /// The lookback window the churn was measured over.
58    pub since: crate::churn::SinceDuration,
59    /// The commit-count threshold the target met to qualify.
60    pub min_commits: u32,
61    /// True when the repository is a shallow clone, so churn counts may
62    /// undercount actual history.
63    pub shallow_clone: bool,
64}
65
66/// Result states that do not represent a churn-analysis failure.
67#[derive(Debug)]
68pub enum TargetChurnOutcome {
69    /// The target met the churn threshold; evidence is attached.
70    Found(TargetChurnEvidence),
71    /// The target was analyzed but did not meet the commit threshold.
72    NoQualifyingChurn {
73        /// Commits observed for the target, when it appeared in history.
74        observed_commits: Option<u32>,
75        /// The lookback window the churn was measured over.
76        since: crate::churn::SinceDuration,
77        /// The commit-count threshold that was not met.
78        min_commits: u32,
79        /// True when the repository is a shallow clone, so churn counts may
80        /// undercount actual history.
81        shallow_clone: bool,
82    },
83    /// Churn could not be measured at all (for example: not a git repository).
84    Unavailable {
85        /// Human-readable reason churn analysis was unavailable.
86        message: String,
87    },
88}
89
90/// Analyze git churn for one normalized project-relative target.
91///
92/// The call is intentionally independent of the full health pipeline. Missing
93/// git is an explicit unavailable outcome, while a failed git analysis remains
94/// an error so callers can preserve partial-evidence warnings.
95pub 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
156/// Validate git prerequisites and return churn data for hotspot analysis.
157///
158/// Uses disk cache when available. Returns `None` if the repo is missing,
159/// `--since` is malformed, or git analysis fails. A missing git repo is treated
160/// as unavailable data rather than a hard error so combined-mode `--format
161/// json` never emits a second JSON document alongside the combined report
162/// (#294); a non-fatal note goes to stderr unless `--quiet` is set.
163pub(super) fn fetch_churn_data(
164    opts: &HealthOptions<'_>,
165    cache_dir: &std::path::Path,
166) -> Option<ChurnFetchResult> {
167    // `--churn-file` imports change history from a normalized JSON file and
168    // bypasses git entirely, so projects on a non-git VCS (Yandex Arc,
169    // Mercurial, Perforce) still get hotspots / ownership. The file is
170    // authoritative for the analysis window, so `--since` is NOT applied to
171    // imported events; it would only mislabel the header, hence `imported_since`.
172    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                // The up-front `health::validate_churn_file` gate already
180                // emitted this error and aborted with exit 2 for a malformed
181                // file, so reaching here means the file changed between the
182                // gate and this re-read (a TOCTOU race). Skip silently rather
183                // than emit a SECOND error document, which would break the
184                // single-document `--format json` contract (#294).
185                tracing::warn!("churn file became unreadable after validation: {e}");
186                return None;
187            }
188        };
189        return Some(ChurnFetchResult {
190            result,
191            since: imported_since(),
192            cache_hit: false,
193            git_log_ms: t.elapsed().as_secs_f64() * 1000.0,
194        });
195    }
196
197    if !crate::churn::is_git_repo(opts.root) {
198        if !opts.quiet {
199            eprintln!("note: hotspot analysis skipped: no git repository found at project root");
200        }
201        return None;
202    }
203
204    let since_input = opts.since.unwrap_or("6m");
205    if let Err(e) = crate::validate::validate_no_control_chars(since_input, "--since") {
206        // A malformed `--since` degrades to "no churn, continue" like the
207        // missing-git-repo branch above: route the diagnostic to `tracing` and
208        // emit NO second JSON document, preserving the single-document
209        // `--format json` contract (#294).
210        tracing::warn!("hotspot analysis skipped: {e}");
211        return None;
212    }
213    let since = match crate::churn::parse_since(since_input) {
214        Ok(s) => s,
215        Err(e) => {
216            tracing::warn!("hotspot analysis skipped: invalid --since: {e}");
217            return None;
218        }
219    };
220
221    let t = std::time::Instant::now();
222    let (churn_result, cache_hit) =
223        crate::churn::analyze_churn_cached(opts.root, &since, cache_dir, opts.no_cache)?;
224    let git_log_ms = t.elapsed().as_secs_f64() * 1000.0;
225
226    Some(ChurnFetchResult {
227        result: churn_result,
228        since,
229        cache_hit,
230        git_log_ms,
231    })
232}
233
234/// Header label for imported churn (`--churn-file`). The imported window is
235/// whatever the wrapper exported, so reusing the `--since` duration ("since 6
236/// months") would misdescribe it. `git_after` is unused on the import path.
237fn imported_since() -> crate::churn::SinceDuration {
238    crate::churn::SinceDuration {
239        git_after: String::new(),
240        display: "imported churn".to_string(),
241    }
242}
243
244/// Find the maximum weighted-commits and complexity-density across eligible files.
245///
246/// Used to normalize hotspot scores into the 0-100 range.
247fn compute_normalization_maxima(
248    file_scores: &[FileHealthScore],
249    churn_files: &rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
250    min_commits: u32,
251) -> (f64, f64) {
252    let mut max_weighted: f64 = 0.0;
253    let mut max_density: f64 = 0.0;
254    for score in file_scores {
255        if let Some(churn) = churn_files.get(&score.path)
256            && churn.commits >= min_commits
257        {
258            max_weighted = max_weighted.max(churn.weighted_commits);
259            max_density = max_density.max(score.complexity_density);
260        }
261    }
262    (max_weighted, max_density)
263}
264
265/// Check whether a file should be excluded from hotspot results
266/// based on workspace filter and ignore patterns.
267fn is_excluded_from_hotspots(
268    path: &std::path::Path,
269    root: &std::path::Path,
270    ignore_set: &globset::GlobSet,
271    ws_roots: Option<&[std::path::PathBuf]>,
272) -> bool {
273    if let Some(ws) = ws_roots
274        && !ws.iter().any(|r| path.starts_with(r))
275    {
276        return true;
277    }
278    if !ignore_set.is_empty() {
279        let relative = path.strip_prefix(root).unwrap_or(path);
280        if ignore_set.is_match(relative) {
281            return true;
282        }
283    }
284    false
285}
286
287/// Compute a normalized hotspot score from churn and complexity data.
288///
289/// Both inputs are normalized against their respective maxima so the result
290/// falls in the 0-100 range (rounded to one decimal).
291fn compute_hotspot_score(
292    weighted_commits: f64,
293    max_weighted: f64,
294    complexity_density: f64,
295    max_density: f64,
296) -> f64 {
297    let norm_churn = if max_weighted > 0.0 {
298        weighted_commits / max_weighted
299    } else {
300        0.0
301    };
302    let norm_complexity = if max_density > 0.0 {
303        complexity_density / max_density
304    } else {
305        0.0
306    };
307    (norm_churn * norm_complexity * 100.0 * 10.0).round() / 10.0
308}
309
310pub(super) struct HotspotComputationInput<'a> {
311    pub(super) opts: &'a HealthOptions<'a>,
312    pub(super) config: &'a fallow_config::ResolvedConfig,
313    pub(super) file_scores: &'a [FileHealthScore],
314    pub(super) ignore_set: &'a globset::GlobSet,
315    pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
316    pub(super) churn_fetch: ChurnFetchResult,
317}
318
319/// Compute hotspot entries by combining pre-fetched churn data with file health scores.
320pub(super) fn compute_hotspots(
321    input: HotspotComputationInput<'_>,
322) -> (Vec<HotspotEntry>, Option<HotspotSummary>) {
323    let HotspotComputationInput {
324        opts,
325        config,
326        file_scores,
327        ignore_set,
328        ws_roots,
329        churn_fetch,
330    } = input;
331    let churn_result = churn_fetch.result;
332    let since = churn_fetch.since;
333
334    let shallow_clone = churn_result.shallow_clone;
335    warn_shallow_clone(opts, shallow_clone);
336
337    let min_commits = opts.min_commits.unwrap_or(3);
338    let (max_weighted, max_density) =
339        compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
340
341    let ownership_cfg = &config.health.ownership;
342    let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
343    let codeowners_owned = load_ownership_codeowners(opts, &config.root);
344    let now_secs = std::time::SystemTime::now()
345        .duration_since(std::time::UNIX_EPOCH)
346        .unwrap_or_default()
347        .as_secs();
348    let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
349        author_pool: &churn_result.author_pool,
350        bot_globs,
351        codeowners: codeowners_owned.as_ref(),
352        email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
353        now_secs,
354    });
355
356    let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
357        file_scores,
358        root: &config.root,
359        ignore_set,
360        ws_roots,
361        churn_files: &churn_result.files,
362        min_commits,
363        max_weighted,
364        max_density,
365        ownership_ctx: ownership_ctx.as_ref(),
366    });
367
368    hotspot_entries.sort_by(|a, b| {
369        b.score
370            .partial_cmp(&a.score)
371            .unwrap_or(std::cmp::Ordering::Equal)
372    });
373
374    let files_analyzed = hotspot_entries.len();
375    let summary = HotspotSummary {
376        since: since.display,
377        min_commits,
378        files_analyzed,
379        files_excluded,
380        shallow_clone,
381    };
382
383    if let Some(top) = opts.top {
384        hotspot_entries.truncate(top);
385    }
386
387    (hotspot_entries, Some(summary))
388}
389
390/// Emit shallow-clone warnings (and the ownership-skew note) when relevant.
391fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
392    if shallow_clone && !opts.quiet {
393        eprintln!(
394            "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
395             Use `git fetch --unshallow` for full history."
396        );
397        if opts.ownership {
398            eprintln!(
399                "Warning: shallow clones inflate single-author dominance, so \
400                 ownership signals will be skewed."
401            );
402        }
403    }
404}
405
406/// Compile the bot-author glob set for ownership analysis, warning on a bad pattern.
407fn load_ownership_bot_globs(
408    opts: &HealthOptions<'_>,
409    ownership_cfg: &fallow_config::OwnershipConfig,
410) -> Option<globset::GlobSet> {
411    opts.ownership.then(|| {
412        compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
413            if !opts.quiet {
414                eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
415            }
416            globset::GlobSet::empty()
417        })
418    })
419}
420
421/// Load CODEOWNERS for ownership analysis, warning on a real parse error only.
422fn load_ownership_codeowners(
423    opts: &HealthOptions<'_>,
424    root: &std::path::Path,
425) -> Option<crate::codeowners::CodeOwners> {
426    opts.ownership
427        .then(|| match crate::codeowners::CodeOwners::load(root, None) {
428            Ok(co) => Some(co),
429            Err(e) => {
430                if !opts.quiet && !e.contains("no CODEOWNERS file found") {
431                    eprintln!("Warning: failed to parse CODEOWNERS: {e}");
432                }
433                None
434            }
435        })
436        .flatten()
437}
438
439/// Read-only inputs for the per-file hotspot-entry loop.
440struct HotspotEntryCtx<'a> {
441    file_scores: &'a [FileHealthScore],
442    root: &'a std::path::Path,
443    ignore_set: &'a globset::GlobSet,
444    ws_roots: Option<&'a [std::path::PathBuf]>,
445    churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
446    min_commits: u32,
447    max_weighted: f64,
448    max_density: f64,
449    ownership_ctx: Option<&'a OwnershipContext<'a>>,
450}
451
452/// Build hotspot entries for eligible files; returns the entries plus the count
453/// of files excluded for not meeting the minimum-commits threshold.
454fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
455    let mut hotspot_entries = Vec::new();
456    let mut files_excluded: usize = 0;
457
458    for score in ctx.file_scores {
459        if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
460            continue;
461        }
462
463        let Some(churn) = ctx.churn_files.get(&score.path) else {
464            continue;
465        };
466        if churn.commits < ctx.min_commits {
467            files_excluded += 1;
468            continue;
469        }
470
471        let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
472        let ownership = ctx
473            .ownership_ctx
474            .and_then(|own| compute_ownership(churn, relative, own));
475
476        hotspot_entries.push(HotspotEntry {
477            path: score.path.clone(),
478            score: compute_hotspot_score(
479                churn.weighted_commits,
480                ctx.max_weighted,
481                score.complexity_density,
482                ctx.max_density,
483            ),
484            commits: churn.commits,
485            weighted_commits: churn.weighted_commits,
486            lines_added: churn.lines_added,
487            lines_deleted: churn.lines_deleted,
488            complexity_density: score.complexity_density,
489            fan_in: score.fan_in,
490            trend: churn.trend,
491            ownership,
492            is_test_path: is_test_path(relative),
493        });
494    }
495
496    (hotspot_entries, files_excluded)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
504        TargetChurnOptions {
505            root,
506            target: std::path::Path::new("src/app.ts"),
507            cache_dir: root.join(".fallow"),
508            no_cache: true,
509            since: None,
510            min_commits: None,
511        }
512    }
513
514    fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
515        let path = root.join("src/app.ts");
516        let mut files = rustc_hash::FxHashMap::default();
517        files.insert(
518            path.clone(),
519            crate::churn::FileChurn {
520                path,
521                commits,
522                weighted_commits: 2.5,
523                lines_added: 20,
524                lines_deleted: 5,
525                trend: crate::churn::ChurnTrend::Accelerating,
526                authors: rustc_hash::FxHashMap::default(),
527            },
528        );
529        crate::churn::ChurnResult {
530            files,
531            shallow_clone: false,
532            author_pool: Vec::new(),
533        }
534    }
535
536    #[test]
537    fn target_churn_returns_only_the_requested_qualifying_file() {
538        let root = std::path::Path::new("/project");
539        let options = target_churn_options(root);
540
541        let outcome = analyze_target_churn_with(
542            &options,
543            |_| true,
544            |_, _, _, _| Some((churn_result(root, 4), false)),
545        )
546        .unwrap();
547
548        let TargetChurnOutcome::Found(evidence) = outcome else {
549            panic!("expected qualifying churn evidence");
550        };
551        assert_eq!(evidence.file.path, root.join("src/app.ts"));
552        assert_eq!(evidence.file.commits, 4);
553        assert_eq!(evidence.min_commits, 3);
554        assert_eq!(evidence.since.display, "6 months");
555    }
556
557    #[test]
558    fn target_churn_distinguishes_no_qualifying_history() {
559        let root = std::path::Path::new("/project");
560        let options = target_churn_options(root);
561
562        let outcome = analyze_target_churn_with(
563            &options,
564            |_| true,
565            |_, _, _, _| Some((churn_result(root, 2), false)),
566        )
567        .unwrap();
568
569        assert!(matches!(
570            outcome,
571            TargetChurnOutcome::NoQualifyingChurn {
572                observed_commits: Some(2),
573                min_commits: 3,
574                ..
575            }
576        ));
577    }
578
579    #[test]
580    fn target_churn_distinguishes_git_unavailable() {
581        let root = std::path::Path::new("/project");
582        let options = target_churn_options(root);
583
584        let outcome = analyze_target_churn_with(
585            &options,
586            |_| false,
587            |_, _, _, _| panic!("churn analysis must not run without git"),
588        )
589        .unwrap();
590
591        assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
592    }
593
594    #[test]
595    fn target_churn_surfaces_analysis_failure() {
596        let root = std::path::Path::new("/project");
597        let options = target_churn_options(root);
598
599        let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
600            .expect_err("failed git analysis must remain explicit");
601
602        assert!(error.contains("git churn analysis failed"));
603    }
604
605    #[test]
606    fn hotspot_score_both_maxima_zero() {
607        assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
608    }
609
610    #[test]
611    fn hotspot_score_max_weighted_zero() {
612        assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
613    }
614
615    #[test]
616    fn hotspot_score_max_density_zero() {
617        assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
618    }
619
620    #[test]
621    fn hotspot_score_equal_normalization() {
622        let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
623        assert!((score - 100.0).abs() < f64::EPSILON);
624    }
625
626    #[test]
627    fn hotspot_score_half_values() {
628        let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
629        assert!((score - 25.0).abs() < f64::EPSILON);
630    }
631
632    #[test]
633    fn excluded_no_filters() {
634        let path = std::path::Path::new("/project/src/foo.ts");
635        let root = std::path::Path::new("/project");
636        let ignore_set = globset::GlobSet::empty();
637
638        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
639    }
640
641    #[test]
642    fn excluded_workspace_filter_mismatch() {
643        let path = std::path::Path::new("/project/packages/b/src/foo.ts");
644        let root = std::path::Path::new("/project");
645        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
646        let ignore_set = globset::GlobSet::empty();
647
648        assert!(is_excluded_from_hotspots(
649            path,
650            root,
651            &ignore_set,
652            Some(&ws_roots)
653        ));
654    }
655
656    #[test]
657    fn excluded_workspace_filter_match() {
658        let path = std::path::Path::new("/project/packages/a/src/foo.ts");
659        let root = std::path::Path::new("/project");
660        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
661        let ignore_set = globset::GlobSet::empty();
662
663        assert!(!is_excluded_from_hotspots(
664            path,
665            root,
666            &ignore_set,
667            Some(&ws_roots)
668        ));
669    }
670
671    #[test]
672    fn excluded_matching_glob() {
673        let path = std::path::Path::new("/project/src/generated/types.ts");
674        let root = std::path::Path::new("/project");
675        let mut builder = globset::GlobSetBuilder::new();
676        builder.add(globset::Glob::new("src/generated/**").unwrap());
677        let ignore_set = builder.build().unwrap();
678
679        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
680    }
681
682    #[test]
683    fn excluded_non_matching_glob() {
684        let path = std::path::Path::new("/project/src/components/Button.tsx");
685        let root = std::path::Path::new("/project");
686        let mut builder = globset::GlobSetBuilder::new();
687        builder.add(globset::Glob::new("src/generated/**").unwrap());
688        let ignore_set = builder.build().unwrap();
689
690        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
691    }
692
693    #[test]
694    fn normalization_maxima_empty_input() {
695        let scores: Vec<FileHealthScore> = vec![];
696        let churn_files = rustc_hash::FxHashMap::default();
697
698        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
699        assert!((max_w).abs() < f64::EPSILON);
700        assert!((max_d).abs() < f64::EPSILON);
701    }
702
703    #[test]
704    fn normalization_maxima_single_file() {
705        let scores = vec![FileHealthScore {
706            path: std::path::PathBuf::from("/src/foo.ts"),
707            fan_in: 0,
708            fan_out: 0,
709            dead_code_ratio: 0.0,
710            complexity_density: 0.75,
711            maintainability_index: 80.0,
712            total_cyclomatic: 15,
713            total_cognitive: 10,
714            function_count: 3,
715            lines: 20,
716            crap_max: 0.0,
717            crap_above_threshold: 0,
718            crap_exempted: 0,
719            crap_effective_threshold: None,
720        }];
721        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
722            rustc_hash::FxHashMap::default();
723        churn_files.insert(
724            std::path::PathBuf::from("/src/foo.ts"),
725            crate::churn::FileChurn {
726                path: std::path::PathBuf::from("/src/foo.ts"),
727                commits: 5,
728                weighted_commits: 4.2,
729                lines_added: 100,
730                lines_deleted: 20,
731                trend: crate::churn::ChurnTrend::Stable,
732                authors: rustc_hash::FxHashMap::default(),
733            },
734        );
735
736        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
737        assert!((max_w - 4.2).abs() < f64::EPSILON);
738        assert!((max_d - 0.75).abs() < f64::EPSILON);
739    }
740
741    /// Hotspot ranking is churn times complexity density and consults no CRAP
742    /// thresholds, so an exemption that clears the risk tag and the coverage
743    /// target must leave hotspot inputs untouched (issue #2228).
744    #[test]
745    fn normalization_maxima_ignore_crap_exemption_fields() {
746        let base = FileHealthScore {
747            path: std::path::PathBuf::from("/src/foo.ts"),
748            fan_in: 0,
749            fan_out: 0,
750            dead_code_ratio: 0.0,
751            complexity_density: 0.75,
752            maintainability_index: 80.0,
753            total_cyclomatic: 15,
754            total_cognitive: 10,
755            function_count: 3,
756            lines: 20,
757            crap_max: 110.0,
758            crap_above_threshold: 2,
759            crap_exempted: 0,
760            crap_effective_threshold: None,
761        };
762        let exempt = FileHealthScore {
763            crap_above_threshold: 0,
764            crap_exempted: 2,
765            crap_effective_threshold: Some(500.0),
766            ..base.clone()
767        };
768        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
769            rustc_hash::FxHashMap::default();
770        churn_files.insert(
771            std::path::PathBuf::from("/src/foo.ts"),
772            crate::churn::FileChurn {
773                path: std::path::PathBuf::from("/src/foo.ts"),
774                commits: 5,
775                weighted_commits: 4.2,
776                lines_added: 100,
777                lines_deleted: 20,
778                trend: crate::churn::ChurnTrend::Stable,
779                authors: rustc_hash::FxHashMap::default(),
780            },
781        );
782
783        let flagged = compute_normalization_maxima(&[base], &churn_files, 3);
784        let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
785        assert_eq!(flagged, exempted);
786    }
787
788    #[test]
789    fn normalization_maxima_below_min_commits() {
790        let scores = vec![FileHealthScore {
791            path: std::path::PathBuf::from("/src/foo.ts"),
792            fan_in: 0,
793            fan_out: 0,
794            dead_code_ratio: 0.0,
795            complexity_density: 0.75,
796            maintainability_index: 80.0,
797            total_cyclomatic: 15,
798            total_cognitive: 10,
799            function_count: 3,
800            lines: 20,
801            crap_max: 0.0,
802            crap_above_threshold: 0,
803            crap_exempted: 0,
804            crap_effective_threshold: None,
805        }];
806        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
807            rustc_hash::FxHashMap::default();
808        churn_files.insert(
809            std::path::PathBuf::from("/src/foo.ts"),
810            crate::churn::FileChurn {
811                path: std::path::PathBuf::from("/src/foo.ts"),
812                commits: 2, // below min_commits=3
813                weighted_commits: 4.2,
814                lines_added: 100,
815                lines_deleted: 20,
816                trend: crate::churn::ChurnTrend::Stable,
817                authors: rustc_hash::FxHashMap::default(),
818            },
819        );
820
821        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
822        assert!((max_w).abs() < f64::EPSILON);
823        assert!((max_d).abs() < f64::EPSILON);
824    }
825
826    #[test]
827    fn normalization_maxima_all_zeros() {
828        let scores = vec![FileHealthScore {
829            path: std::path::PathBuf::from("/src/foo.ts"),
830            fan_in: 0,
831            fan_out: 0,
832            dead_code_ratio: 0.0,
833            complexity_density: 0.0,
834            maintainability_index: 100.0,
835            total_cyclomatic: 0,
836            total_cognitive: 0,
837            function_count: 1,
838            lines: 10,
839            crap_max: 0.0,
840            crap_above_threshold: 0,
841            crap_exempted: 0,
842            crap_effective_threshold: None,
843        }];
844        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
845            rustc_hash::FxHashMap::default();
846        churn_files.insert(
847            std::path::PathBuf::from("/src/foo.ts"),
848            crate::churn::FileChurn {
849                path: std::path::PathBuf::from("/src/foo.ts"),
850                commits: 5,
851                weighted_commits: 0.0,
852                lines_added: 0,
853                lines_deleted: 0,
854                trend: crate::churn::ChurnTrend::Stable,
855                authors: rustc_hash::FxHashMap::default(),
856            },
857        );
858
859        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
860        assert!((max_w).abs() < f64::EPSILON);
861        assert!((max_d).abs() < f64::EPSILON);
862    }
863
864    #[test]
865    fn hotspot_score_high_churn_low_complexity() {
866        let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
867        assert!((score - 10.0).abs() < f64::EPSILON);
868    }
869
870    #[test]
871    fn hotspot_score_low_churn_high_complexity() {
872        let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
873        assert!((score - 10.0).abs() < f64::EPSILON);
874    }
875
876    #[test]
877    fn hotspot_score_rounding() {
878        let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
879        assert!((score - 11.1).abs() < f64::EPSILON);
880    }
881
882    #[test]
883    fn hotspot_score_very_small_values() {
884        let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
885        assert!((score).abs() < 0.1);
886    }
887
888    #[test]
889    fn hotspot_score_weighted_exceeds_max() {
890        let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
891        assert!((score - 75.0).abs() < f64::EPSILON);
892    }
893
894    #[test]
895    fn normalization_maxima_multiple_files_picks_max() {
896        let scores = vec![
897            FileHealthScore {
898                path: std::path::PathBuf::from("/src/a.ts"),
899                fan_in: 0,
900                fan_out: 0,
901                dead_code_ratio: 0.0,
902                complexity_density: 0.5,
903                maintainability_index: 80.0,
904                total_cyclomatic: 10,
905                total_cognitive: 5,
906                function_count: 2,
907                lines: 50,
908                crap_max: 0.0,
909                crap_above_threshold: 0,
910                crap_exempted: 0,
911                crap_effective_threshold: None,
912            },
913            FileHealthScore {
914                path: std::path::PathBuf::from("/src/b.ts"),
915                fan_in: 0,
916                fan_out: 0,
917                dead_code_ratio: 0.0,
918                complexity_density: 1.2, // highest density
919                maintainability_index: 60.0,
920                total_cyclomatic: 30,
921                total_cognitive: 20,
922                function_count: 5,
923                lines: 100,
924                crap_max: 0.0,
925                crap_above_threshold: 0,
926                crap_exempted: 0,
927                crap_effective_threshold: None,
928            },
929            FileHealthScore {
930                path: std::path::PathBuf::from("/src/c.ts"),
931                fan_in: 0,
932                fan_out: 0,
933                dead_code_ratio: 0.0,
934                complexity_density: 0.8,
935                maintainability_index: 70.0,
936                total_cyclomatic: 20,
937                total_cognitive: 15,
938                function_count: 4,
939                lines: 80,
940                crap_max: 0.0,
941                crap_above_threshold: 0,
942                crap_exempted: 0,
943                crap_effective_threshold: None,
944            },
945        ];
946        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
947            rustc_hash::FxHashMap::default();
948        churn_files.insert(
949            std::path::PathBuf::from("/src/a.ts"),
950            crate::churn::FileChurn {
951                path: std::path::PathBuf::from("/src/a.ts"),
952                commits: 5,
953                weighted_commits: 3.0,
954                lines_added: 50,
955                lines_deleted: 10,
956                trend: crate::churn::ChurnTrend::Stable,
957                authors: rustc_hash::FxHashMap::default(),
958            },
959        );
960        churn_files.insert(
961            std::path::PathBuf::from("/src/b.ts"),
962            crate::churn::FileChurn {
963                path: std::path::PathBuf::from("/src/b.ts"),
964                commits: 10,
965                weighted_commits: 8.5, // highest weighted
966                lines_added: 200,
967                lines_deleted: 50,
968                trend: crate::churn::ChurnTrend::Accelerating,
969                authors: rustc_hash::FxHashMap::default(),
970            },
971        );
972        churn_files.insert(
973            std::path::PathBuf::from("/src/c.ts"),
974            crate::churn::FileChurn {
975                path: std::path::PathBuf::from("/src/c.ts"),
976                commits: 7,
977                weighted_commits: 5.0,
978                lines_added: 100,
979                lines_deleted: 30,
980                trend: crate::churn::ChurnTrend::Cooling,
981                authors: rustc_hash::FxHashMap::default(),
982            },
983        );
984
985        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
986        assert!((max_w - 8.5).abs() < f64::EPSILON);
987        assert!((max_d - 1.2).abs() < f64::EPSILON);
988    }
989
990    #[test]
991    fn normalization_maxima_mixed_above_and_below_threshold() {
992        let scores = vec![
993            FileHealthScore {
994                path: std::path::PathBuf::from("/src/frequent.ts"),
995                fan_in: 0,
996                fan_out: 0,
997                dead_code_ratio: 0.0,
998                complexity_density: 0.4,
999                maintainability_index: 85.0,
1000                total_cyclomatic: 8,
1001                total_cognitive: 4,
1002                function_count: 2,
1003                lines: 40,
1004                crap_max: 0.0,
1005                crap_above_threshold: 0,
1006                crap_exempted: 0,
1007                crap_effective_threshold: None,
1008            },
1009            FileHealthScore {
1010                path: std::path::PathBuf::from("/src/rare.ts"),
1011                fan_in: 0,
1012                fan_out: 0,
1013                dead_code_ratio: 0.0,
1014                complexity_density: 2.0, // higher but excluded
1015                maintainability_index: 50.0,
1016                total_cyclomatic: 40,
1017                total_cognitive: 30,
1018                function_count: 8,
1019                lines: 200,
1020                crap_max: 0.0,
1021                crap_above_threshold: 0,
1022                crap_exempted: 0,
1023                crap_effective_threshold: None,
1024            },
1025        ];
1026        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1027            rustc_hash::FxHashMap::default();
1028        churn_files.insert(
1029            std::path::PathBuf::from("/src/frequent.ts"),
1030            crate::churn::FileChurn {
1031                path: std::path::PathBuf::from("/src/frequent.ts"),
1032                commits: 10,
1033                weighted_commits: 7.0,
1034                lines_added: 150,
1035                lines_deleted: 40,
1036                trend: crate::churn::ChurnTrend::Stable,
1037                authors: rustc_hash::FxHashMap::default(),
1038            },
1039        );
1040        churn_files.insert(
1041            std::path::PathBuf::from("/src/rare.ts"),
1042            crate::churn::FileChurn {
1043                path: std::path::PathBuf::from("/src/rare.ts"),
1044                commits: 1, // below min_commits=5
1045                weighted_commits: 0.9,
1046                lines_added: 10,
1047                lines_deleted: 2,
1048                trend: crate::churn::ChurnTrend::Cooling,
1049                authors: rustc_hash::FxHashMap::default(),
1050            },
1051        );
1052
1053        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1054        assert!((max_w - 7.0).abs() < f64::EPSILON);
1055        assert!((max_d - 0.4).abs() < f64::EPSILON);
1056    }
1057
1058    #[test]
1059    fn normalization_maxima_file_score_without_churn() {
1060        let scores = vec![FileHealthScore {
1061            path: std::path::PathBuf::from("/src/no_churn.ts"),
1062            fan_in: 0,
1063            fan_out: 0,
1064            dead_code_ratio: 0.0,
1065            complexity_density: 5.0,
1066            maintainability_index: 30.0,
1067            total_cyclomatic: 100,
1068            total_cognitive: 80,
1069            function_count: 20,
1070            lines: 500,
1071            crap_max: 0.0,
1072            crap_above_threshold: 0,
1073            crap_exempted: 0,
1074            crap_effective_threshold: None,
1075        }];
1076        let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1077            rustc_hash::FxHashMap::default();
1078
1079        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1080        assert!((max_w).abs() < f64::EPSILON);
1081        assert!((max_d).abs() < f64::EPSILON);
1082    }
1083
1084    #[test]
1085    fn normalization_maxima_min_commits_zero() {
1086        let scores = vec![FileHealthScore {
1087            path: std::path::PathBuf::from("/src/foo.ts"),
1088            fan_in: 0,
1089            fan_out: 0,
1090            dead_code_ratio: 0.0,
1091            complexity_density: 0.3,
1092            maintainability_index: 90.0,
1093            total_cyclomatic: 3,
1094            total_cognitive: 2,
1095            function_count: 1,
1096            lines: 10,
1097            crap_max: 0.0,
1098            crap_above_threshold: 0,
1099            crap_exempted: 0,
1100            crap_effective_threshold: None,
1101        }];
1102        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1103            rustc_hash::FxHashMap::default();
1104        churn_files.insert(
1105            std::path::PathBuf::from("/src/foo.ts"),
1106            crate::churn::FileChurn {
1107                path: std::path::PathBuf::from("/src/foo.ts"),
1108                commits: 0,
1109                weighted_commits: 0.0,
1110                lines_added: 0,
1111                lines_deleted: 0,
1112                trend: crate::churn::ChurnTrend::Stable,
1113                authors: rustc_hash::FxHashMap::default(),
1114            },
1115        );
1116
1117        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1118        assert!((max_w).abs() < f64::EPSILON);
1119        assert!((max_d - 0.3).abs() < f64::EPSILON);
1120    }
1121
1122    #[test]
1123    fn normalization_maxima_exactly_at_threshold() {
1124        let scores = vec![FileHealthScore {
1125            path: std::path::PathBuf::from("/src/foo.ts"),
1126            fan_in: 0,
1127            fan_out: 0,
1128            dead_code_ratio: 0.0,
1129            complexity_density: 1.5,
1130            maintainability_index: 65.0,
1131            total_cyclomatic: 25,
1132            total_cognitive: 18,
1133            function_count: 5,
1134            lines: 120,
1135            crap_max: 0.0,
1136            crap_above_threshold: 0,
1137            crap_exempted: 0,
1138            crap_effective_threshold: None,
1139        }];
1140        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1141            rustc_hash::FxHashMap::default();
1142        churn_files.insert(
1143            std::path::PathBuf::from("/src/foo.ts"),
1144            crate::churn::FileChurn {
1145                path: std::path::PathBuf::from("/src/foo.ts"),
1146                commits: 3, // exactly at min_commits=3
1147                weighted_commits: 2.8,
1148                lines_added: 60,
1149                lines_deleted: 15,
1150                trend: crate::churn::ChurnTrend::Stable,
1151                authors: rustc_hash::FxHashMap::default(),
1152            },
1153        );
1154
1155        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1156        assert!((max_w - 2.8).abs() < f64::EPSILON);
1157        assert!((max_d - 1.5).abs() < f64::EPSILON);
1158    }
1159
1160    #[test]
1161    fn excluded_workspace_and_glob_combined() {
1162        let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1163        let root = std::path::Path::new("/project");
1164        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1165        let mut builder = globset::GlobSetBuilder::new();
1166        builder.add(globset::Glob::new("**/generated/**").unwrap());
1167        let ignore_set = builder.build().unwrap();
1168
1169        assert!(is_excluded_from_hotspots(
1170            path,
1171            root,
1172            &ignore_set,
1173            Some(&ws_roots)
1174        ));
1175    }
1176
1177    #[test]
1178    fn excluded_workspace_match_but_glob_no_match() {
1179        let path = std::path::Path::new("/project/packages/a/src/index.ts");
1180        let root = std::path::Path::new("/project");
1181        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1182        let mut builder = globset::GlobSetBuilder::new();
1183        builder.add(globset::Glob::new("**/generated/**").unwrap());
1184        let ignore_set = builder.build().unwrap();
1185
1186        assert!(!is_excluded_from_hotspots(
1187            path,
1188            root,
1189            &ignore_set,
1190            Some(&ws_roots)
1191        ));
1192    }
1193
1194    #[test]
1195    fn excluded_path_equals_root() {
1196        let path = std::path::Path::new("/project");
1197        let root = std::path::Path::new("/project");
1198        let ignore_set = globset::GlobSet::empty();
1199
1200        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1201    }
1202
1203    #[test]
1204    fn excluded_path_outside_root() {
1205        let path = std::path::Path::new("/other/src/foo.ts");
1206        let root = std::path::Path::new("/project");
1207        let mut builder = globset::GlobSetBuilder::new();
1208        builder.add(globset::Glob::new("src/foo.ts").unwrap());
1209        let ignore_set = builder.build().unwrap();
1210
1211        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1212    }
1213
1214    #[test]
1215    fn excluded_multiple_globs_first_matches() {
1216        let path = std::path::Path::new("/project/dist/bundle.js");
1217        let root = std::path::Path::new("/project");
1218        let mut builder = globset::GlobSetBuilder::new();
1219        builder.add(globset::Glob::new("dist/**").unwrap());
1220        builder.add(globset::Glob::new("node_modules/**").unwrap());
1221        let ignore_set = builder.build().unwrap();
1222
1223        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1224    }
1225
1226    #[test]
1227    fn excluded_multiple_globs_second_matches() {
1228        let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1229        let root = std::path::Path::new("/project");
1230        let mut builder = globset::GlobSetBuilder::new();
1231        builder.add(globset::Glob::new("dist/**").unwrap());
1232        builder.add(globset::Glob::new("node_modules/**").unwrap());
1233        let ignore_set = builder.build().unwrap();
1234
1235        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1236    }
1237
1238    #[test]
1239    fn excluded_multiple_globs_none_matches() {
1240        let path = std::path::Path::new("/project/src/app.ts");
1241        let root = std::path::Path::new("/project");
1242        let mut builder = globset::GlobSetBuilder::new();
1243        builder.add(globset::Glob::new("dist/**").unwrap());
1244        builder.add(globset::Glob::new("node_modules/**").unwrap());
1245        let ignore_set = builder.build().unwrap();
1246
1247        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1248    }
1249}