Skip to main content

fallow_engine/health/
scoring.rs

1use fallow_output::{DirectCallerEvidence, DirectCallerSymbolEvidence, FileHealthScore};
2
3use crate::module_graph::StaticTestCoverage;
4
5use super::coverage_gaps::compute_coverage_gaps;
6pub(super) use super::coverage_gaps::{CoverageGapData, build_coverage_summary};
7use super::threshold_overrides::ThresholdOverrideResolver;
8
9/// Output from `compute_file_scores`, including auxiliary data for refactoring targets.
10pub struct FileScoreOutput {
11    pub(crate) scores: Vec<FileHealthScore>,
12    /// Static coverage gaps derived from runtime-vs-test reachability.
13    pub(crate) coverage: CoverageGapData,
14    /// Files participating in circular dependencies (absolute paths).
15    pub(crate) circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
16    /// Top 3 functions by cognitive complexity per file (name, line, cognitive score).
17    pub(crate) top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
18    /// Files that are configured entry points.
19    pub(crate) entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
20    /// Total number of value exports per file (for dead code gate: total_value_exports >= 3).
21    pub(crate) value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
22    /// Unused export names per file (for evidence linking).
23    pub(crate) unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
24    /// Cycle members per file: maps each file to the other files in its cycle.
25    pub(crate) cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
26    /// Direct importers per file, with the symbols imported by each caller.
27    pub(crate) direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
28    /// Aggregate counts from AnalysisResults for vital signs (project-wide).
29    pub(crate) analysis_counts: crate::vital_signs::AnalysisCounts,
30    /// Located prop-drilling chains from the analysis results (empty when the
31    /// opt-in `prop-drilling` rule is off, since the detector populates no chains
32    /// then). Drives the small capped health penalty, the hotspot surface, and
33    /// the `health --format json` `prop_drilling_chains` array.
34    pub(crate) prop_drilling_chains: Vec<fallow_types::output_dead_code::PropDrillingChainFinding>,
35    /// Per-component render fan-in (JSX render SITES + distinct parents) plus the
36    /// precomputed concentration aggregates, cloned from the analysis results.
37    /// `None` on non-React projects. Descriptive blast-radius signal: feeds the
38    /// `VitalSigns` render-fan-in aggregates and the hotspot/react drill-down
39    /// `rendered in N places` line (keyed back to file paths).
40    pub(crate) render_fan_in: Option<fallow_types::results::RenderFanInMetric>,
41    /// Per-path snapshot of analysis findings, used to recompute
42    /// [`crate::vital_signs::AnalysisCounts`] for an arbitrary subset of files
43    /// (workspace scoping, `--group-by` partitioning).
44    pub(crate) analysis_snapshot: AnalysisCountsSnapshot,
45    /// Istanbul match stats: functions matched / total (only meaningful with Istanbul model).
46    pub(crate) istanbul_matched: usize,
47    pub(crate) istanbul_total: usize,
48    /// Per-file, per-function CRAP data used to emit `--max-crap` findings.
49    /// Absolute paths match `FileHealthScore.path`. Absent entries indicate the
50    /// file had zero functions.
51    pub(crate) per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
52    /// Provenance map for synthetic Angular `<template>` findings whose CRAP
53    /// was inherited from the owning `.component.ts` via the inverse
54    /// `templateUrl` edge. Keys are the template `.html` absolute paths,
55    /// values are the owner `.ts` absolute paths (the path used for the
56    /// `inherited from foo.component.ts` human-output suffix). Absent for
57    /// non-template files and for templates with no `.ts` owner.
58    pub(crate) template_inherit_provenance:
59        rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
60}
61
62struct FileScoreOutputParts<'a> {
63    graph: &'a fallow_graph::graph::ModuleGraph,
64    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
65    results: &'a crate::results::AnalysisResults,
66    scores: Vec<FileHealthScore>,
67    coverage: CoverageGapData,
68    circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
69    top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
70    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
71    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
72    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
73    cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
74    direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
75    istanbul_matched: usize,
76    istanbul_total: usize,
77    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
78    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
79}
80
81/// Per-path snapshot of analysis-pipeline findings, retained alongside the
82/// pre-aggregated `analysis_counts` so that workspace- or group-scoped runs
83/// can recompute counts without re-running the full pipeline.
84///
85/// All paths are absolute (matching `AnalysisResults` and `FileHealthScore`).
86#[derive(Clone, Default)]
87pub struct AnalysisCountsSnapshot {
88    /// One entry per unused file.
89    unused_file_paths: Vec<std::path::PathBuf>,
90    /// One entry per unused value or type export, keyed by the file containing
91    /// the export.
92    unused_export_paths: Vec<std::path::PathBuf>,
93    /// One entry per unused dependency across `dependencies`,
94    /// `devDependencies`, and `optionalDependencies`, keyed by the
95    /// `package.json` path that declared it.
96    unused_dep_package_paths: Vec<std::path::PathBuf>,
97    /// Each cycle as the set of file paths it contains. Used to count cycles
98    /// that touch any file inside a workspace.
99    circular_dep_groups: Vec<Vec<std::path::PathBuf>>,
100    /// Total exports per module (`module.exports.len()` in the graph), used
101    /// as the denominator for `dead_export_pct`.
102    module_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
103}
104
105impl AnalysisCountsSnapshot {
106    /// Compute analysis counts for the file subset selected by `subset`.
107    ///
108    /// Returns `*defaults` when `subset.is_full()`. Otherwise recomputes
109    /// every count by retaining paths the subset accepts. Cycles are counted
110    /// when any cycle member is in the subset.
111    ///
112    /// Unused-dep counting is special-cased: dep entries are keyed by their
113    /// `package.json` path, which is never a source file and therefore never
114    /// matches the source-file membership of a `Paths` subset. For
115    /// `SubsetFilter::Paths`, a `package.json` is considered
116    /// in scope when at least one source file in the subset sits inside its
117    /// directory (the dep's owning workspace).
118    ///
119    /// `total_deps` is propagated unchanged from `defaults`; it is not
120    /// available per-subset today (mirrors the project-wide behaviour).
121    pub(crate) fn counts_for(
122        &self,
123        subset: &crate::health::SubsetFilter<'_>,
124        defaults: &crate::vital_signs::AnalysisCounts,
125    ) -> crate::vital_signs::AnalysisCounts {
126        if subset.is_full() {
127            return *defaults;
128        }
129        let dead_files = self
130            .unused_file_paths
131            .iter()
132            .filter(|p| subset.matches(p))
133            .count();
134        let dead_exports = self
135            .unused_export_paths
136            .iter()
137            .filter(|p| subset.matches(p))
138            .count();
139        let unused_deps = self
140            .unused_dep_package_paths
141            .iter()
142            .filter(|dep_path| dep_in_subset(subset, dep_path))
143            .count();
144        let circular_deps = self
145            .circular_dep_groups
146            .iter()
147            .filter(|cycle| cycle.iter().any(|p| subset.matches(p)))
148            .count();
149        let total_exports = self
150            .module_export_counts
151            .iter()
152            .filter(|(p, _)| subset.matches(p))
153            .map(|(_, n)| *n)
154            .sum();
155        crate::vital_signs::AnalysisCounts {
156            total_exports,
157            dead_files,
158            dead_exports,
159            unused_deps,
160            circular_deps,
161            total_deps: defaults.total_deps,
162        }
163    }
164}
165
166/// Return true when an unused dependency's `package.json` path belongs to
167/// the subset.
168///
169/// For [`crate::health::SubsetFilter::Paths`] the dep's containing workspace
170/// (its `package.json` parent directory) is considered in scope when at
171/// least one source file in the subset lives under that directory.
172fn dep_in_subset(subset: &crate::health::SubsetFilter<'_>, dep_path: &std::path::Path) -> bool {
173    match subset {
174        crate::health::SubsetFilter::Full => true,
175        crate::health::SubsetFilter::Paths(set) => {
176            let Some(workspace_root) = dep_path.parent() else {
177                return false;
178            };
179            set.iter().any(|p| p.starts_with(workspace_root))
180        }
181    }
182}
183
184/// Aggregate complexity totals from a parsed module.
185///
186/// Returns `(total_cyclomatic, total_cognitive, function_count, lines)`.
187#[expect(
188    clippy::cast_possible_truncation,
189    reason = "line count is bounded by source file size"
190)]
191fn aggregate_complexity(module: &crate::source::ModuleInfo) -> (u32, u32, usize, u32) {
192    let cyc: u32 = module
193        .complexity
194        .iter()
195        .map(|f| u32::from(f.cyclomatic))
196        .sum();
197    let cog: u32 = module
198        .complexity
199        .iter()
200        .map(|f| u32::from(f.cognitive))
201        .sum();
202    let funcs = module.complexity.len();
203    let lines = module.line_offsets.len() as u32;
204    (cyc, cog, funcs, lines)
205}
206
207/// Compute the dead code ratio for a single file.
208///
209/// Returns the fraction of VALUE exports with zero references (0.0-1.0).
210/// Type-only exports (interfaces, type aliases) are excluded from both
211/// numerator and denominator to avoid inflating the ratio for well-typed
212/// codebases. Returns 1.0 if the entire file is unused, 0.0 if it has no
213/// value exports.
214fn compute_dead_code_ratio(
215    path: &std::path::Path,
216    exports: &[fallow_graph::graph::ExportSymbol],
217    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
218    unused_exports_by_path: &rustc_hash::FxHashMap<&std::path::Path, usize>,
219) -> f64 {
220    if unused_files.contains(path) {
221        return 1.0;
222    }
223    let value_exports = exports.iter().filter(|e| !e.is_type_only).count();
224    if value_exports == 0 {
225        return 0.0;
226    }
227    let unused = unused_exports_by_path.get(path).copied().unwrap_or(0);
228    (unused as f64 / value_exports as f64).min(1.0)
229}
230
231/// Compute complexity density: total cyclomatic / lines of code.
232///
233/// Returns 0.0 when the file has no lines.
234fn compute_complexity_density(total_cyclomatic: u32, lines: u32) -> f64 {
235    if lines > 0 {
236        f64::from(total_cyclomatic) / f64::from(lines)
237    } else {
238        0.0
239    }
240}
241
242/// CRAP score threshold (inclusive). CC=5 untested gives exactly 30 (5^2 + 5),
243/// matching the canonical CRAP threshold from Savoia & Evans (2007).
244pub(super) const CRAP_THRESHOLD: f64 = 30.0;
245
246/// Effective-threshold inputs for the CRAP columns of file scoring: the run
247/// resolver plus the `enforce_crap` bit from the health scope.
248#[derive(Clone, Copy)]
249pub(super) struct CrapScoreThresholds<'a> {
250    pub(super) resolver: &'a ThresholdOverrideResolver,
251    pub(super) enforce_crap: bool,
252}
253
254/// Per-function effective-CRAP-ceiling lookup for one file's scoring loop:
255/// the run resolver bound to the file's project-relative path, so the CRAP
256/// loops can resolve each function's ceiling where the function name is in
257/// hand. `enforce_crap` mirrors `HealthScope::enforce_crap`: a global ceiling
258/// of `0` disables CRAP enforcement, so every function counts as exempt at the
259/// canonical baseline instead of breaching a degenerate `0` ceiling.
260pub(super) struct CrapCeilingLookup<'a> {
261    resolver: &'a ThresholdOverrideResolver,
262    relative: &'a std::path::Path,
263    enforce_crap: bool,
264}
265
266/// Threshold-relative CRAP signals accumulated over one file's functions.
267///
268/// `above` and `exempted` count the ROUNDED per-function CRAP value, the same
269/// value the findings pipeline compares against the effective ceiling, so a
270/// boundary function cannot produce a finding without being counted here, or
271/// be counted here without producing a finding.
272#[derive(Debug, Default)]
273struct CrapThresholdSignals {
274    /// Functions whose rounded CRAP meets or exceeds their effective ceiling.
275    above: usize,
276    /// Functions at or above the canonical 30.0 baseline whose effective
277    /// ceiling exempts them (all baseline-breaching functions when CRAP
278    /// enforcement is disabled).
279    exempted: usize,
280    /// Lowest effective ceiling among the observed functions.
281    min_ceiling: Option<f64>,
282}
283
284impl<'a> CrapCeilingLookup<'a> {
285    pub(super) fn new(thresholds: CrapScoreThresholds<'a>, relative: &'a std::path::Path) -> Self {
286        Self {
287            resolver: thresholds.resolver,
288            relative,
289            enforce_crap: thresholds.enforce_crap,
290        }
291    }
292
293    /// Fold one function's rounded CRAP value into the file's
294    /// threshold-relative signals.
295    fn observe(&self, function: &str, crap_rounded: f64, signals: &mut CrapThresholdSignals) {
296        let ceiling = self.resolver.effective_max_crap(self.relative, function);
297        signals.min_ceiling = Some(signals.min_ceiling.map_or(ceiling, |m| m.min(ceiling)));
298        if !self.enforce_crap {
299            if crap_rounded >= CRAP_THRESHOLD {
300                signals.exempted += 1;
301            }
302        } else if crap_rounded >= ceiling {
303            signals.above += 1;
304        } else if crap_rounded >= CRAP_THRESHOLD {
305            signals.exempted += 1;
306        }
307    }
308}
309
310/// Compute per-function CRAP scores using the static binary model.
311///
312/// Binary model: test-reachable file -> CRAP = CC, untested -> CRAP = CC^2 + CC.
313/// Superseded by `compute_crap_scores_estimated` but retained for test coverage
314/// of the binary formula behavior.
315///
316/// Returns `(max_crap, count_above_threshold)`.
317#[cfg(test)]
318#[expect(
319    clippy::suboptimal_flops,
320    reason = "cc * cc + cc matches the CRAP formula specification"
321)]
322fn compute_crap_scores_binary(
323    complexity: &[fallow_types::extract::FunctionComplexity],
324    is_test_reachable: bool,
325) -> (f64, usize) {
326    if complexity.is_empty() {
327        return (0.0, 0);
328    }
329    let mut max = 0.0_f64;
330    let mut above = 0usize;
331    for f in complexity {
332        let cc = f64::from(f.cyclomatic);
333        let crap = if is_test_reachable { cc } else { cc * cc + cc };
334        max = max.max(crap);
335        if crap >= CRAP_THRESHOLD {
336            above += 1;
337        }
338    }
339    ((max * 10.0).round() / 10.0, above)
340}
341
342/// Per-function CRAP data used to emit `--max-crap` findings.
343#[derive(Debug, Clone, Copy)]
344pub struct PerFunctionCrap {
345    /// 1-based line number of the function's definition.
346    pub(crate) line: u32,
347    /// 0-based column of the function's definition. Required alongside `line`
348    /// to disambiguate curried arrows that share a start line, e.g.
349    /// `(x) => (y) => {...}`. Without `col`, two `PerFunctionCrap` entries
350    /// would collide in the (path, line) finding index and one function's
351    /// CRAP score could be attached to another function's identity.
352    pub(crate) col: u32,
353    /// Computed CRAP score, rounded to one decimal place.
354    pub(crate) crap: f64,
355    /// Coverage percentage used to compute `crap`, when Istanbul matched the
356    /// function. `None` for estimated coverage or unmatched functions.
357    pub(crate) coverage_pct: Option<f64>,
358    /// Bucketed coverage tier used to drive action selection in JSON output.
359    /// Populated for both Istanbul-matched and estimated CRAP rows so the
360    /// action builder does not need to recompute reachability state.
361    pub(crate) coverage_tier: fallow_output::CoverageTier,
362    /// Provenance of `coverage_tier` and `crap`. `Istanbul` for direct fnMap
363    /// matches, `Estimated` for graph-based fallbacks against the finding's
364    /// own file, `EstimatedComponentInherited` for the template-inherit path
365    /// that reaches the owning Angular `.component.ts` through the inverse
366    /// `templateUrl` edge. Threaded into `ComplexityViolation.coverage_source` by
367    /// `merge_crap_findings`.
368    pub(crate) coverage_source: fallow_output::CoverageSource,
369}
370
371/// Istanbul CRAP result: CRAP scores plus match statistics.
372#[derive(Debug)]
373struct IstanbulCrapResult {
374    pub max_crap: f64,
375    /// Threshold-relative counts and the lowest effective ceiling.
376    pub signals: CrapThresholdSignals,
377    /// Functions that found a match in Istanbul data.
378    pub matched: usize,
379    /// Total functions evaluated.
380    pub total: usize,
381    /// Per-function CRAP data indexed by function position within `complexity`.
382    pub per_function: Vec<PerFunctionCrap>,
383}
384
385/// Compute per-function CRAP scores using Istanbul coverage data.
386///
387/// For each function, looks up its per-function statement coverage percentage
388/// from the Istanbul data and applies the canonical CRAP formula:
389/// `CRAP = CC^2 * (1 - cov/100)^3 + CC`
390///
391/// Functions not found in the coverage data fall back to the estimated model
392/// using the file's test-reachability status.
393///
394/// Returns CRAP scores and match statistics for reporting.
395fn compute_crap_scores_istanbul(
396    complexity: &[fallow_types::extract::FunctionComplexity],
397    file_coverage: Option<&IstanbulFileCoverage>,
398    is_test_reachable: bool,
399    ceilings: &CrapCeilingLookup<'_>,
400) -> IstanbulCrapResult {
401    if complexity.is_empty() {
402        return IstanbulCrapResult {
403            max_crap: 0.0,
404            signals: CrapThresholdSignals::default(),
405            matched: 0,
406            total: 0,
407            per_function: Vec::new(),
408        };
409    }
410    let mut max = 0.0_f64;
411    let mut signals = CrapThresholdSignals::default();
412    let mut matched = 0usize;
413    let mut total = 0usize;
414    let mut per_function = Vec::with_capacity(complexity.len());
415    for f in complexity {
416        // Synthetic template-family units carry no measurable coverage (an
417        // Istanbul fnMap can never contain them), so they are excluded from
418        // the CRAP dimension entirely: no per-function entry, no max /
419        // above-threshold contribution, no match-statistics slot.
420        if fallow_types::extract::is_synthetic_template_unit(&f.name) {
421            continue;
422        }
423        total += 1;
424        let (crap, coverage_pct, tier, source) =
425            crap_for_function(f, file_coverage, is_test_reachable, &mut matched);
426        let crap_rounded = (crap * 10.0).round() / 10.0;
427        max = max.max(crap);
428        ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
429        per_function.push(PerFunctionCrap {
430            line: f.line,
431            col: f.col,
432            crap: crap_rounded,
433            coverage_pct,
434            coverage_tier: tier,
435            coverage_source: source,
436        });
437    }
438    IstanbulCrapResult {
439        max_crap: (max * 10.0).round() / 10.0,
440        signals,
441        matched,
442        total,
443        per_function,
444    }
445}
446
447/// Resolve one function's `(crap, coverage_pct, tier, source)` from Istanbul
448/// coverage, falling back to the test-reachability estimate model. Increments
449/// `matched` when a real coverage value is found.
450#[expect(
451    clippy::suboptimal_flops,
452    reason = "cc * cc + cc matches the CRAP formula specification"
453)]
454fn crap_for_function(
455    f: &fallow_types::extract::FunctionComplexity,
456    file_coverage: Option<&IstanbulFileCoverage>,
457    is_test_reachable: bool,
458    matched: &mut usize,
459) -> (
460    f64,
461    Option<f64>,
462    fallow_output::CoverageTier,
463    fallow_output::CoverageSource,
464) {
465    let cc = f64::from(f.cyclomatic);
466    let lookup = file_coverage.and_then(|fc| fc.lookup(f.name.as_str(), f.line, f.col));
467    if let Some(cov_pct) = lookup {
468        *matched += 1;
469        return (
470            crap_formula(cc, cov_pct),
471            Some(cov_pct),
472            fallow_output::CoverageTier::from_pct(cov_pct),
473            fallow_output::CoverageSource::Istanbul,
474        );
475    }
476    if is_test_reachable {
477        return (
478            cc,
479            None,
480            fallow_output::CoverageTier::from_pct(INDIRECT_TEST_COVERAGE_ESTIMATE),
481            fallow_output::CoverageSource::Estimated,
482        );
483    }
484    (
485        cc * cc + cc,
486        None,
487        fallow_output::CoverageTier::None,
488        fallow_output::CoverageSource::Estimated,
489    )
490}
491
492/// Estimated coverage for functions directly referenced by test-reachable modules.
493/// An export imported in a test file likely exercises most of the function body.
494const DIRECT_TEST_COVERAGE_ESTIMATE: f64 = 85.0;
495
496/// Estimated coverage for functions in test-reachable files but not directly
497/// referenced by tests. The file is imported by tests, so the function may
498/// be exercised indirectly, but with lower confidence.
499const INDIRECT_TEST_COVERAGE_ESTIMATE: f64 = 40.0;
500const MAX_DIRECT_CALLER_EVIDENCE: usize = 5;
501
502/// Compute per-function CRAP scores using graph-based coverage estimation.
503///
504/// For each function, estimates coverage from the module graph:
505/// - Function name matches an export with test-reachable references: 85%
506/// - File is test-reachable but function not directly referenced: 40%
507/// - File is not test-reachable at all: 0%
508///
509/// Applies the canonical CRAP formula with these estimates.
510/// Returns `(max_crap, count_above_threshold)`.
511/// Estimated CRAP result: score aggregates plus per-function data.
512#[derive(Debug)]
513struct EstimatedCrapResult {
514    pub max_crap: f64,
515    /// Threshold-relative counts and the lowest effective ceiling.
516    pub signals: CrapThresholdSignals,
517    pub per_function: Vec<PerFunctionCrap>,
518}
519
520fn compute_crap_scores_estimated(
521    complexity: &[fallow_types::extract::FunctionComplexity],
522    test_referenced_exports: &rustc_hash::FxHashSet<String>,
523    is_test_reachable: bool,
524    coverage_source: fallow_output::CoverageSource,
525    ceilings: &CrapCeilingLookup<'_>,
526) -> EstimatedCrapResult {
527    if complexity.is_empty() {
528        return EstimatedCrapResult {
529            max_crap: 0.0,
530            signals: CrapThresholdSignals::default(),
531            per_function: Vec::new(),
532        };
533    }
534    let mut max = 0.0_f64;
535    let mut signals = CrapThresholdSignals::default();
536    let mut per_function = Vec::with_capacity(complexity.len());
537    for f in complexity {
538        // Template-family units leave the CRAP dimension: their name never
539        // appears in `test_referenced_exports`, so the estimate could only
540        // ever restate the file's reachability as a disguised cyclomatic gate.
541        if fallow_types::extract::is_synthetic_template_unit(&f.name) {
542            continue;
543        }
544        let cc = f64::from(f.cyclomatic);
545        let estimated_coverage = if test_referenced_exports.contains(f.name.as_str()) {
546            DIRECT_TEST_COVERAGE_ESTIMATE
547        } else if is_test_reachable {
548            INDIRECT_TEST_COVERAGE_ESTIMATE
549        } else {
550            0.0
551        };
552        let crap = crap_formula(cc, estimated_coverage);
553        let crap_rounded = (crap * 10.0).round() / 10.0;
554        max = max.max(crap);
555        ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
556        per_function.push(PerFunctionCrap {
557            line: f.line,
558            col: f.col,
559            crap: crap_rounded,
560            coverage_pct: None,
561            coverage_tier: fallow_output::CoverageTier::from_pct(estimated_coverage),
562            coverage_source,
563        });
564    }
565    EstimatedCrapResult {
566        max_crap: (max * 10.0).round() / 10.0,
567        signals,
568        per_function,
569    }
570}
571
572/// Inherited CRAP context for a synthetic `<template>` finding on an Angular
573/// `.html` template. Populated by `build_template_inherit_contexts` for every
574/// `.html` module that has a `<template>` `FunctionComplexity` entry AND is
575/// reached by at least one non-test `.ts` importer via the `templateUrl`
576/// `SideEffect` edge.
577///
578/// The reachability bit is the OR across all non-test `.ts` owners (any
579/// tested owner makes the template tested); the `test_referenced_exports`
580/// set is the union of each owner's directly-test-referenced export names;
581/// the provenance path points at the chosen owner for human output. When
582/// multiple owners exist, prefer the first test-reachable one so the
583/// "inherited from" suffix points at a meaningful owner rather than an
584/// arbitrary first match.
585#[derive(Debug, Clone)]
586pub(super) struct TemplateInheritContext {
587    pub is_test_reachable: bool,
588    pub test_referenced_exports: rustc_hash::FxHashSet<String>,
589    /// The owning `.ts` file path used for human-output provenance
590    /// (`coverage: partial (inherited from foo.component.ts)`). Set to the
591    /// first test-reachable owner when one exists, otherwise the first
592    /// non-test owner. Absolute path; the human formatter strips it.
593    pub provenance_owner: std::path::PathBuf,
594}
595
596/// Build the inverse `templateUrl` redirect map: for every `.html` module
597/// carrying a synthetic `<template>` `FunctionComplexity` entry, walk
598/// `reverse_deps` to find every `.ts` (or `.component.ts`) importer that is
599/// NOT a test entry point, and compute an aggregate `TemplateInheritContext`
600/// that the CRAP scoring loop can use to redirect reachability + test refs
601/// to the owning component file.
602///
603/// Test-file owners are excluded because Angular spec files do not declare
604/// `templateUrl`; if a `.spec.ts` is the only importer of a `.html`, the
605/// template is genuinely orphaned and the existing fallback (estimated
606/// against the `.html`'s own reachability) is the right answer.
607///
608/// The `.ts` / `.tsx` / `.mts` / `.cts` extension gate intentionally lets
609/// `.d.ts` ambient declarations through, but Angular component classes are
610/// not emitted into `.d.ts` files (which model APIs, not runtime behaviour)
611/// and `templateUrl` SideEffect edges flow only from concrete `@Component`
612/// decorators. A `.d.ts` importer of a `.html` would be a structural
613/// anomaly upstream, not a meaningful owner, so the gate stays simple.
614///
615/// Templates with zero non-test `.ts` owners receive no entry, so the
616/// scoring loop falls through to the existing path unchanged.
617fn build_template_inherit_contexts(
618    graph: &fallow_graph::graph::ModuleGraph,
619    test_coverage: StaticTestCoverage<'_>,
620    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
621    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
622) -> rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext> {
623    let mut out = rustc_hash::FxHashMap::default();
624    for node in &graph.modules {
625        if let Some(context) =
626            template_inherit_context_for_node(node, graph, test_coverage, module_by_id, file_paths)
627        {
628            out.insert(node.file_id, context);
629        }
630    }
631    out
632}
633
634fn template_inherit_context_for_node(
635    node: &fallow_graph::graph::ModuleNode,
636    graph: &fallow_graph::graph::ModuleGraph,
637    test_coverage: StaticTestCoverage<'_>,
638    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
639    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
640) -> Option<TemplateInheritContext> {
641    if !is_template_inherit_candidate(node, module_by_id, file_paths) {
642        return None;
643    }
644    let importers = graph.reverse_deps.get(node.file_id.0 as usize)?;
645    template_inherit_context_from_importers(
646        importers,
647        graph,
648        test_coverage,
649        module_by_id,
650        file_paths,
651    )
652}
653
654fn is_template_inherit_candidate(
655    node: &fallow_graph::graph::ModuleNode,
656    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
657    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
658) -> bool {
659    let Some(path) = file_paths.get(&node.file_id) else {
660        return false;
661    };
662    if !path
663        .extension()
664        .and_then(|ext| ext.to_str())
665        .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
666    {
667        return false;
668    }
669    module_by_id.get(&node.file_id).is_some_and(|module| {
670        module
671            .complexity
672            .iter()
673            .any(|finding| finding.name.as_str() == "<template>")
674    })
675}
676
677fn template_inherit_context_from_importers(
678    importers: &[crate::discover::FileId],
679    graph: &fallow_graph::graph::ModuleGraph,
680    test_coverage: StaticTestCoverage<'_>,
681    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
682    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
683) -> Option<TemplateInheritContext> {
684    let mut any_reachable = false;
685    let mut combined_refs = rustc_hash::FxHashSet::default();
686    let mut provenance: Option<std::path::PathBuf> = None;
687    let mut first_owner: Option<std::path::PathBuf> = None;
688
689    for &importer_id in importers {
690        let Some((owner_node, owner_path)) =
691            template_owner(importer_id, graph, module_by_id, file_paths)
692        else {
693            continue;
694        };
695        if first_owner.is_none() {
696            first_owner = Some((*owner_path).clone());
697        }
698        if test_coverage.covers_file(owner_node.file_id) {
699            any_reachable = true;
700            provenance.get_or_insert_with(|| (*owner_path).clone());
701            let refs = build_test_referenced_exports(&owner_node.exports, test_coverage);
702            combined_refs.extend(refs);
703        }
704    }
705
706    let provenance_owner = provenance.or(first_owner)?;
707    Some(TemplateInheritContext {
708        is_test_reachable: any_reachable,
709        test_referenced_exports: combined_refs,
710        provenance_owner,
711    })
712}
713
714fn template_owner<'a>(
715    importer_id: crate::discover::FileId,
716    graph: &'a fallow_graph::graph::ModuleGraph,
717    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
718    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
719) -> Option<(&'a fallow_graph::graph::ModuleNode, &'a std::path::PathBuf)> {
720    let owner_node = graph.modules.get(importer_id.0 as usize)?;
721    let owner_path = *file_paths.get(&importer_id)?;
722    if !is_template_owner_path(owner_path) || graph.test_entry_points.contains(&importer_id) {
723        return None;
724    }
725    let owner_has_component = module_by_id
726        .get(&importer_id)
727        .is_some_and(|module| module.has_angular_component_template_url);
728    owner_has_component.then_some((owner_node, owner_path))
729}
730
731fn is_template_owner_path(path: &std::path::Path) -> bool {
732    path.extension()
733        .and_then(|ext| ext.to_str())
734        .is_some_and(|ext| {
735            matches!(
736                ext.to_ascii_lowercase().as_str(),
737                "ts" | "tsx" | "mts" | "cts"
738            )
739        })
740}
741
742/// Build the set of export names that have at least one test-reachable reference.
743///
744/// This is the per-function signal: if an export named "foo" has a reference from
745/// a test-reachable module, the function "foo" is considered directly tested.
746fn build_test_referenced_exports(
747    exports: &[fallow_graph::graph::ExportSymbol],
748    test_coverage: StaticTestCoverage<'_>,
749) -> rustc_hash::FxHashSet<String> {
750    let mut set = rustc_hash::FxHashSet::default();
751    for export in exports {
752        if export.is_type_only {
753            continue;
754        }
755        let has_test_ref = test_coverage.covers_any_reference(export);
756        if has_test_ref {
757            set.insert(export.name.to_string());
758        }
759    }
760    set
761}
762
763fn collect_direct_callers(
764    graph: &fallow_graph::graph::ModuleGraph,
765    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
766) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>> {
767    let mut callers_by_target = rustc_hash::FxHashMap::default();
768    for node in &graph.modules {
769        let Some(target_path) = file_paths.get(&node.file_id) else {
770            continue;
771        };
772        let mut callers = graph
773            .direct_importer_summaries(node.file_id)
774            .into_iter()
775            .filter_map(|summary| {
776                file_paths
777                    .get(&summary.source)
778                    .map(|caller_path| DirectCallerEvidence {
779                        path: (*caller_path).clone(),
780                        symbols: summary
781                            .symbols
782                            .into_iter()
783                            .map(|symbol| DirectCallerSymbolEvidence {
784                                imported: symbol.imported,
785                                local: symbol.local,
786                                type_only: symbol.type_only,
787                            })
788                            .collect(),
789                    })
790            })
791            .collect::<Vec<_>>();
792        callers.sort_by(|a, b| a.path.cmp(&b.path));
793        callers.truncate(MAX_DIRECT_CALLER_EVIDENCE);
794        if !callers.is_empty() {
795            callers_by_target.insert((*target_path).clone(), callers);
796        }
797    }
798    callers_by_target
799}
800
801/// Canonical CRAP formula: `CC^2 * (1 - cov/100)^3 + CC`.
802/// At 100% coverage: CRAP = CC. At 0% coverage: CRAP = CC^2 + CC.
803#[expect(
804    clippy::suboptimal_flops,
805    reason = "explicit multiplication matches the CRAP formula specification"
806)]
807fn crap_formula(cc: f64, coverage_pct: f64) -> f64 {
808    let uncovered = 1.0 - coverage_pct / 100.0;
809    cc * cc * uncovered * uncovered * uncovered + cc
810}
811
812/// Maximum column drift tolerated when the anonymous-by-position fallback
813/// matches a candidate on a nearby line. Wide enough to accept curried arrows
814/// and chained callbacks that share a leading indent, tight enough to reject
815/// `function foo()` at column 0 when the only candidate is a multiline-arrow
816/// declaration alias at the typical `const x = async (` column.
817const ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT: u32 = 16;
818
819/// Pre-processed per-function coverage data for a single file,
820/// derived from Istanbul `coverage-final.json`.
821pub struct IstanbulFileCoverage {
822    /// Per-function coverage percentages, keyed by (name, line, col). Lines
823    /// are 1-based and columns are 0-based, matching both fallow's
824    /// `FunctionComplexity` positions and Istanbul `Position`s.
825    ///
826    /// Istanbul producers are not consistent about `FnEntry.line`: some use
827    /// the declaration line, while others use the body start. The loader
828    /// therefore indexes both the producer's effective line and
829    /// `decl.start`, so multiline TypeScript signatures still match the
830    /// function start that fallow extracts.
831    functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
832}
833
834impl IstanbulFileCoverage {
835    /// Look up coverage for a function by name, start line, and start column.
836    ///
837    /// Resolution order:
838    /// 1. Exact `(name, line, col)` match.
839    /// 2. Name-only fuzzy match within ±2 lines (tolerates formatter drift),
840    ///    tie-broken by smallest `(line, col)` distance from the target.
841    /// 3. Anonymous fallback: among Istanbul `(anonymous_N)` entries within
842    ///    ±2 lines, pick the one closest in `(line, col)` to the target.
843    ///    Bail only if two candidates tie on distance, which would be
844    ///    genuinely ambiguous.
845    ///
846    /// Step 3 covers arrow-function exports where fallow extracts the binding
847    /// identifier (`const myHandler = () => {...}` yields `myHandler`) while
848    /// Istanbul records the function as anonymous. `load_istanbul_coverage`
849    /// indexes declaration aliases so standard Istanbul producers still
850    /// participate in this fallback. See issues #155, #166, #181, and #370.
851    pub fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
852        if let Some(&pct) = self.functions.get(&(name.to_string(), line, col)) {
853            return Some(pct);
854        }
855        if let Some(pct) = self
856            .functions
857            .iter()
858            .filter(|((n, l, _), _)| n == name && l.abs_diff(line) <= 2)
859            .min_by_key(|((_, l, c), _)| (l.abs_diff(line), c.abs_diff(col)))
860            .map(|(_, &pct)| pct)
861        {
862            return Some(pct);
863        }
864        let mut nearest_distance: Option<(u32, u32)> = None;
865        let mut nearest_pct: Option<f64> = None;
866        let mut tied = false;
867        for ((n, l, c), &pct) in &self.functions {
868            if !n.starts_with("(anonymous_") {
869                continue;
870            }
871            if l.abs_diff(line) > 2 {
872                continue;
873            }
874            let dist = (l.abs_diff(line), c.abs_diff(col));
875            if dist.0 > 0 && dist.1 > ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT {
876                continue;
877            }
878            match nearest_distance {
879                None => {
880                    nearest_distance = Some(dist);
881                    nearest_pct = Some(pct);
882                    tied = false;
883                }
884                Some(prev) if dist < prev => {
885                    nearest_distance = Some(dist);
886                    nearest_pct = Some(pct);
887                    tied = false;
888                }
889                Some(prev) if dist == prev => {
890                    tied = true;
891                }
892                Some(_) => {}
893            }
894        }
895        if tied { None } else { nearest_pct }
896    }
897}
898
899/// Loaded Istanbul coverage data, keyed by canonical file path.
900pub struct IstanbulCoverage {
901    files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
902}
903
904impl IstanbulCoverage {
905    /// Get coverage data for a file path.
906    pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
907        self.files.get(path)
908    }
909}
910
911/// Precedence decision for per-function CRAP coverage inputs.
912///
913/// Template inheritance wins first so Angular `.html` template findings can
914/// use the owning `.component.ts` reachability context. Istanbul wins next,
915/// even when the current file is missing from the coverage map, because that
916/// path still records unmatched functions in the run-level match counters.
917/// Plain graph-estimated coverage is the final fallback.
918enum CrapCoverageResolution<'a> {
919    TemplateInherited(&'a TemplateInheritContext),
920    Istanbul {
921        file_coverage: Option<&'a IstanbulFileCoverage>,
922    },
923    StaticEstimated,
924}
925
926fn resolve_crap_coverage<'a>(
927    template_inherit: Option<&'a TemplateInheritContext>,
928    istanbul_coverage: Option<&'a IstanbulCoverage>,
929    path: &std::path::Path,
930) -> CrapCoverageResolution<'a> {
931    if let Some(inherit_ctx) = template_inherit {
932        CrapCoverageResolution::TemplateInherited(inherit_ctx)
933    } else if let Some(istanbul) = istanbul_coverage {
934        let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
935        CrapCoverageResolution::Istanbul {
936            file_coverage: istanbul.get(&canonical),
937        }
938    } else {
939        CrapCoverageResolution::StaticEstimated
940    }
941}
942
943/// Load Istanbul coverage data from a `coverage-final.json` file or directory.
944///
945/// Auto-detect a `coverage-final.json` file in common locations relative to the project root.
946///
947/// Checks (in order): `coverage/coverage-final.json`, `.nyc_output/coverage-final.json`.
948/// Returns the first path found, or `None` if no coverage file exists.
949pub(super) fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
950    let candidates = [
951        root.join("coverage/coverage-final.json"),
952        root.join(".nyc_output/coverage-final.json"),
953    ];
954    candidates.into_iter().find(|p| p.is_file())
955}
956
957/// Resolve a relative path against the fallow project root. Returns `path`
958/// unchanged when it is absolute or `project_root` is `None`. Matches the
959/// convention every other path-shaped CLI input uses, so a monorepo CI run
960/// invoked from the workspace root with `--root sub-project` finds
961/// `sub-project/relative/path.json` instead of `cwd/relative/path.json`.
962pub fn resolve_relative_to_root(
963    path: &std::path::Path,
964    project_root: Option<&std::path::Path>,
965) -> std::path::PathBuf {
966    if fallow_types::path_util::is_absolute_path_any_platform(path) {
967        return path.to_path_buf();
968    }
969    match project_root {
970        Some(root) => root.join(path),
971        None => path.to_path_buf(),
972    }
973}
974
975/// If `path` is a directory, looks for `coverage-final.json` inside it.
976/// Parses the Istanbul JSON format and pre-computes per-function statement
977/// coverage percentages for efficient lookup during CRAP scoring.
978///
979/// When `coverage_root` is provided, file paths in the Istanbul data are rebased:
980/// the `coverage_root` prefix is stripped and `project_root` is prepended, enabling
981/// cross-environment matching (e.g., coverage from CI used on a local checkout).
982///
983/// `path` itself is resolved against `project_root` when relative, so callers
984/// can pass `--coverage coverage/foo.json` from a parent directory and have it
985/// land under the `--root` they configured.
986pub(super) fn load_istanbul_coverage(
987    path: &std::path::Path,
988    coverage_root: Option<&std::path::Path>,
989    project_root: Option<&std::path::Path>,
990) -> Result<IstanbulCoverage, String> {
991    super::validate_coverage_root_absolute(coverage_root)?;
992    let resolved = resolve_relative_to_root(path, project_root);
993    let file_path = if resolved.is_dir() {
994        let candidate = resolved.join("coverage-final.json");
995        if candidate.is_file() {
996            candidate
997        } else {
998            return Err(format!(
999                "no coverage-final.json found in {}",
1000                resolved.display()
1001            ));
1002        }
1003    } else {
1004        resolved
1005    };
1006
1007    let json = std::fs::read_to_string(&file_path)
1008        .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
1009
1010    let raw: std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage> =
1011        oxc_coverage_instrument::parse_coverage_map(&json).map_err(|e| {
1012            format!(
1013                "failed to parse coverage data from {}: {e}",
1014                file_path.display()
1015            )
1016        })?;
1017
1018    let mut files = rustc_hash::FxHashMap::default();
1019    for file_cov in raw.values() {
1020        let raw_path = std::path::PathBuf::from(&file_cov.path);
1021        let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
1022            raw_path
1023                .strip_prefix(cov_root)
1024                .map(|rel| proj_root.join(rel))
1025                .unwrap_or(raw_path)
1026        } else {
1027            raw_path
1028        };
1029        let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
1030
1031        let mut functions = rustc_hash::FxHashMap::default();
1032        for (fn_id, fn_entry) in &file_cov.fn_map {
1033            let coverage_pct = compute_function_statement_coverage(file_cov, fn_id, fn_entry);
1034            insert_istanbul_function_coverage(&mut functions, fn_entry, coverage_pct);
1035        }
1036
1037        files.insert(canonical, IstanbulFileCoverage { functions });
1038    }
1039
1040    Ok(IstanbulCoverage { files })
1041}
1042
1043fn insert_istanbul_function_coverage(
1044    functions: &mut rustc_hash::FxHashMap<(String, u32, u32), f64>,
1045    fn_entry: &oxc_coverage_instrument::FnEntry,
1046    coverage_pct: f64,
1047) {
1048    let name = fn_entry.name.clone();
1049    let primary = (
1050        name.clone(),
1051        effective_istanbul_fn_line(fn_entry),
1052        effective_istanbul_fn_col(fn_entry),
1053    );
1054    functions.insert(primary.clone(), coverage_pct);
1055
1056    let declaration = (name, fn_entry.decl.start.line, fn_entry.decl.start.column);
1057    if declaration != primary {
1058        functions.entry(declaration).or_insert(coverage_pct);
1059    }
1060}
1061
1062fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
1063    if fn_entry.line > 0 {
1064        fn_entry.line
1065    } else {
1066        fn_entry.decl.start.line
1067    }
1068}
1069
1070/// Effective 0-based start column for an Istanbul function entry. `FnEntry`
1071/// has no top-level `column` field, so we always read it off
1072/// `decl.start.column`. Both fallow's `FunctionComplexity.col` and Istanbul's
1073/// `Position::column` are 0-based, so they match directly.
1074fn effective_istanbul_fn_col(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
1075    fn_entry.decl.start.column
1076}
1077
1078/// Compute statement-level coverage percentage for a single function.
1079///
1080/// Maps statements from `statementMap` to the function's body range (`loc`)
1081/// and computes the fraction with non-zero hit counts. When no statements
1082/// fall within the function body (e.g., one-liner arrow functions, getters),
1083/// falls back to the function hit count as a binary signal.
1084fn compute_function_statement_coverage(
1085    file_cov: &oxc_coverage_instrument::FileCoverage,
1086    fn_id: &str,
1087    fn_entry: &oxc_coverage_instrument::FnEntry,
1088) -> f64 {
1089    let fn_start_line = fn_entry.loc.start.line;
1090    let fn_start_col = fn_entry.loc.start.column;
1091    let fn_end_line = fn_entry.loc.end.line;
1092    let fn_end_col = fn_entry.loc.end.column;
1093
1094    let mut total = 0u32;
1095    let mut covered = 0u32;
1096
1097    for (stmt_id, stmt_loc) in &file_cov.statement_map {
1098        let after_start = stmt_loc.start.line > fn_start_line
1099            || (stmt_loc.start.line == fn_start_line && stmt_loc.start.column >= fn_start_col);
1100        let before_end = stmt_loc.end.line < fn_end_line
1101            || (stmt_loc.end.line == fn_end_line && stmt_loc.end.column <= fn_end_col);
1102
1103        if after_start && before_end {
1104            total += 1;
1105            if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
1106                covered += 1;
1107            }
1108        }
1109    }
1110
1111    if total == 0 {
1112        let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
1113        if hit > 0 { 100.0 } else { 0.0 }
1114    } else {
1115        f64::from(covered) / f64::from(total) * 100.0
1116    }
1117}
1118
1119/// Count unused VALUE exports per file path for O(1) lookup.
1120///
1121/// Type-only exports (interfaces, type aliases) are intentionally excluded ---
1122/// they are a different concern than unused functions/components.
1123fn count_unused_exports_by_path(
1124    unused_exports: &[crate::results::UnusedExportFinding],
1125) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
1126    let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
1127    for exp in unused_exports {
1128        *map.entry(exp.export.path.as_path()).or_default() += 1;
1129    }
1130    map
1131}
1132
1133/// Compute the maintainability index for a single file.
1134///
1135/// Formula:
1136/// ```text
1137/// dampening = min(lines / 50, 1.0)
1138/// fan_out_penalty = min(ln(fan_out + 1) * 4, 15)
1139/// MI = 100 - (complexity_density * 30 * dampening) - (dead_code_ratio * 20) - fan_out_penalty
1140/// ```
1141///
1142/// The dampening factor prevents complexity density from dominating the score
1143/// on small files. A 5-line utility with CC=2 has density 0.40, but is trivially
1144/// readable; without dampening it scores worse than a 192-line function with CC=57
1145/// (density 0.30). Files under 50 lines get proportionally reduced density weight.
1146///
1147/// Fan-out uses logarithmic scaling capped at 15 points to reflect diminishing
1148/// marginal risk (the 30th import is less concerning than the 5th) and prevent
1149/// composition-root files from being unfairly penalized.
1150///
1151/// Clamped to \[0, 100\]. Higher is better.
1152fn compute_maintainability_index(
1153    complexity_density: f64,
1154    dead_code_ratio: f64,
1155    fan_out: usize,
1156    lines: u32,
1157) -> f64 {
1158    let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
1159    let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
1160    #[expect(
1161        clippy::suboptimal_flops,
1162        reason = "formula matches documented specification"
1163    )]
1164    let score = 100.0
1165        - (complexity_density * 30.0 * dampening)
1166        - (dead_code_ratio * 20.0)
1167        - fan_out_penalty;
1168    score.clamp(0.0, 100.0)
1169}
1170
1171fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
1172    (100.0 - score.maintainability_index).clamp(0.0, 100.0)
1173}
1174
1175/// True when the file's CRAP signal is fully covered by configuration: nothing
1176/// meets its effective ceiling while something would have been flagged at the
1177/// canonical 30.0 baseline, or CRAP enforcement is disabled entirely
1178/// (`max_crap_threshold <= 0`). Mirrors the findings pipeline, which emits no
1179/// CRAP finding in exactly these states, so the row must not read `risk`.
1180#[must_use]
1181pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
1182    max_crap_threshold <= 0.0 || (score.crap_above_threshold == 0 && score.crap_exempted > 0)
1183}
1184
1185/// CRAP concern bands generalized over the row's effective ceiling `t`
1186/// (`crap_effective_threshold`, falling back to the run global). At `t = 30`
1187/// the breakpoints are the historical (15, 30, 100). A fully exempt file
1188/// scores `0.0`: band generalization alone cannot drop an exempted file below
1189/// its structural concern, so the tag would keep reading `risk` against a run
1190/// with zero findings.
1191fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
1192    if file_score_fully_crap_exempt(score, max_crap_threshold) {
1193        return 0.0;
1194    }
1195    let crap_max = score.crap_max;
1196    let t = score.crap_effective_threshold.unwrap_or(max_crap_threshold);
1197    let half = t / 2.0;
1198    let saturation = t * 10.0 / 3.0;
1199    if crap_max <= 0.0 {
1200        0.0
1201    } else if crap_max < half {
1202        (crap_max / half) * 45.0
1203    } else if crap_max < t {
1204        ((crap_max - half) / half).mul_add(30.0, 45.0)
1205    } else if crap_max < saturation {
1206        ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
1207    } else {
1208        100.0
1209    }
1210}
1211
1212fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
1213    file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
1214}
1215
1216/// Which signal places a file at its triage rank: its structural quality (low
1217/// maintainability index) or its untested complexity (CRAP risk). Surfaced per
1218/// row so the human file-scores table can label why a file sits where it does
1219/// when the two axes disagree (e.g. a low-CRAP file outranking a higher-CRAP
1220/// one because its MI is the worse signal).
1221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1222pub enum FileScoreConcern {
1223    /// Ranked by structural quality: a low maintainability index.
1224    Structural,
1225    /// Ranked by untested complexity: a high CRAP score.
1226    Risk,
1227}
1228
1229impl FileScoreConcern {
1230    /// Short lowercase label for the human file-scores table.
1231    pub const fn label(self) -> &'static str {
1232        match self {
1233            Self::Structural => "structure",
1234            Self::Risk => "risk",
1235        }
1236    }
1237}
1238
1239/// Classify which concern drove `score` to its rank. A file with no CRAP
1240/// concern (no CRAP risk at all, or every breaching function exempted by its
1241/// effective ceiling) is always `Structural`; otherwise the larger concern
1242/// wins, with ties (and the boundary where the two are equal) resolving to
1243/// `Risk` because untested complexity is the more urgent signal to act on.
1244///
1245/// `max_crap_threshold` is the run global (`summary.max_crap_threshold`), the
1246/// fallback ceiling for rows without a `crap_effective_threshold` of their own.
1247pub fn file_score_concern_axis(
1248    score: &FileHealthScore,
1249    max_crap_threshold: f64,
1250) -> FileScoreConcern {
1251    let crap_concern = file_score_crap_concern(score, max_crap_threshold);
1252    if crap_concern <= 0.0 {
1253        FileScoreConcern::Structural
1254    } else if crap_concern >= file_score_structural_concern(score) {
1255        FileScoreConcern::Risk
1256    } else {
1257        FileScoreConcern::Structural
1258    }
1259}
1260
1261fn compare_file_score_triage(
1262    a: &FileHealthScore,
1263    b: &FileHealthScore,
1264    max_crap_threshold: f64,
1265) -> std::cmp::Ordering {
1266    file_score_triage_concern(b, max_crap_threshold)
1267        .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
1268        .then_with(|| b.crap_max.total_cmp(&a.crap_max))
1269        .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
1270        .then_with(|| a.path.cmp(&b.path))
1271}
1272
1273/// Inputs for [`compute_file_scores`], bundled so the analysis artifacts stay
1274/// a separate owned argument.
1275#[derive(Clone, Copy)]
1276pub(super) struct FileScoreComputeInput<'a> {
1277    pub(super) modules: &'a [crate::source::ModuleInfo],
1278    pub(super) file_paths:
1279        &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1280    pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1281    pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
1282    pub(super) root: &'a std::path::Path,
1283    pub(super) crap_thresholds: CrapScoreThresholds<'a>,
1284}
1285
1286/// Compute per-file health scores using a pre-computed analysis output.
1287///
1288/// The caller provides an `AnalysisOutput` (with graph and dead code results)
1289/// so this function does not need to re-run the analysis pipeline. Complexity
1290/// density is derived from the already-parsed modules.
1291pub(super) fn compute_file_scores(
1292    input: FileScoreComputeInput<'_>,
1293    analysis_output: crate::results::DeadCodeAnalysisArtifacts,
1294) -> Result<FileScoreOutput, String> {
1295    let FileScoreComputeInput {
1296        modules,
1297        file_paths,
1298        changed_files,
1299        istanbul_coverage,
1300        root,
1301        crap_thresholds,
1302    } = input;
1303    let retained_graph = analysis_output.graph.ok_or("graph not available")?;
1304    let test_coverage = retained_graph.static_test_coverage();
1305    let graph = retained_graph.as_graph();
1306    let results = &analysis_output.results;
1307
1308    let circular_files = collect_circular_files(results);
1309    let top_complex_fns = collect_top_complex_fns(modules, file_paths);
1310    let cycle_members = collect_cycle_members(results);
1311    let direct_callers = collect_direct_callers(graph, file_paths);
1312    let unused_export_names = collect_unused_export_names(results);
1313
1314    let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
1315        .unused_files
1316        .iter()
1317        .map(|f| f.file.path.as_path())
1318        .collect();
1319
1320    let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
1321
1322    let FileScoreCoverageSetup {
1323        module_by_id,
1324        coverage,
1325    } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
1326
1327    let template_inherit =
1328        build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
1329
1330    let mut acc = accumulate_file_scores(
1331        unused_export_names,
1332        &FileScoreLoopCtx {
1333            graph,
1334            test_coverage,
1335            file_paths,
1336            module_by_id: &module_by_id,
1337            unused_files: &unused_files,
1338            unused_exports_by_path: &unused_exports_by_path,
1339            template_inherit: &template_inherit,
1340            istanbul_coverage,
1341            root,
1342            crap_thresholds,
1343        },
1344    );
1345    acc.scores = finalize_file_score_list(
1346        acc.scores,
1347        changed_files,
1348        crap_thresholds.resolver.global.crap,
1349    );
1350
1351    Ok(build_file_score_output(FileScoreOutputParts {
1352        graph,
1353        file_paths,
1354        results,
1355        scores: acc.scores,
1356        coverage,
1357        circular_files,
1358        top_complex_fns,
1359        entry_points: acc.entry_points,
1360        value_export_counts: acc.value_export_counts,
1361        unused_export_names: acc.unused_export_names,
1362        cycle_members,
1363        direct_callers,
1364        istanbul_matched: acc.istanbul_matched,
1365        istanbul_total: acc.istanbul_total,
1366        per_function_crap: acc.per_function_crap,
1367        template_inherit,
1368    }))
1369}
1370
1371/// Read-only inputs threaded into the per-node file-score loop.
1372struct FileScoreLoopCtx<'a> {
1373    graph: &'a fallow_graph::graph::ModuleGraph,
1374    test_coverage: StaticTestCoverage<'a>,
1375    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1376    module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1377    unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
1378    unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
1379    template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1380    istanbul_coverage: Option<&'a IstanbulCoverage>,
1381    /// Project root used to relativize paths into the override resolver's glob
1382    /// space, matching the findings pipeline's `strip_prefix` on the same root.
1383    root: &'a std::path::Path,
1384    crap_thresholds: CrapScoreThresholds<'a>,
1385}
1386
1387/// Mutable accumulators populated by the per-node file-score loop.
1388struct FileScoreAccumulator {
1389    scores: Vec<FileHealthScore>,
1390    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
1391    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
1392    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1393    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1394    istanbul_matched: usize,
1395    istanbul_total: usize,
1396}
1397
1398impl FileScoreAccumulator {
1399    /// Empty accumulator with the score vector pre-sized to the module count.
1400    fn with_capacity(modules: usize) -> Self {
1401        FileScoreAccumulator {
1402            scores: Vec::with_capacity(modules),
1403            entry_points: rustc_hash::FxHashSet::default(),
1404            value_export_counts: rustc_hash::FxHashMap::default(),
1405            unused_export_names: rustc_hash::FxHashMap::default(),
1406            per_function_crap: rustc_hash::FxHashMap::default(),
1407            istanbul_matched: 0,
1408            istanbul_total: 0,
1409        }
1410    }
1411}
1412
1413/// Drive the per-node loop, returning an accumulator with one score per
1414/// analyzable file. `unused_export_names` seeds the accumulator's same field.
1415fn accumulate_file_scores(
1416    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1417    ctx: &FileScoreLoopCtx<'_>,
1418) -> FileScoreAccumulator {
1419    let mut acc = FileScoreAccumulator {
1420        unused_export_names,
1421        ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
1422    };
1423    for node in &ctx.graph.modules {
1424        let Some(path) = ctx.file_paths.get(&node.file_id) else {
1425            continue;
1426        };
1427        record_entry_point(&mut acc.entry_points, node, path);
1428        let score = compute_one_file_score(&mut acc, ctx, node, path);
1429        acc.scores.push(score);
1430    }
1431    acc
1432}
1433
1434/// Apply the changed-file scope filter, drop zero-function barrels, and sort by
1435/// risk-aware triage concern.
1436fn finalize_file_score_list(
1437    mut scores: Vec<FileHealthScore>,
1438    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1439    max_crap_threshold: f64,
1440) -> Vec<FileHealthScore> {
1441    if let Some(changed) = changed_files {
1442        scores.retain(|s| changed.contains(&s.path));
1443    }
1444    scores.retain(|s| s.function_count > 0);
1445    scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
1446    scores
1447}
1448
1449/// Compute the `FileHealthScore` for one node and fold its side data into `acc`.
1450fn compute_one_file_score(
1451    acc: &mut FileScoreAccumulator,
1452    ctx: &FileScoreLoopCtx<'_>,
1453    node: &fallow_graph::graph::ModuleNode,
1454    path: &std::path::Path,
1455) -> FileHealthScore {
1456    let fan_in = ctx
1457        .graph
1458        .reverse_deps
1459        .get(node.file_id.0 as usize)
1460        .map_or(0, Vec::len);
1461    let fan_out = node.edge_range.len();
1462
1463    let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
1464        .module_by_id
1465        .get(&node.file_id)
1466        .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
1467
1468    let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
1469    let path_owned = path.to_path_buf();
1470    acc.value_export_counts
1471        .insert(path_owned.clone(), value_exports);
1472    record_unused_file_export_names(
1473        path_owned.as_path(),
1474        &node.exports,
1475        ctx.unused_files,
1476        &mut acc.unused_export_names,
1477    );
1478
1479    let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
1480        compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
1481
1482    let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
1483    let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative);
1484    let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
1485    acc.istanbul_matched += crap.istanbul_matched;
1486    acc.istanbul_total += crap.istanbul_total;
1487    record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
1488
1489    // `crap_effective_threshold` is the file's lowest effective ceiling, on the
1490    // wire only when it differs from the run global. Both sides come from the
1491    // same resolved configuration values, so any difference beyond epsilon is a
1492    // real override, never rounding noise.
1493    let global_crap = ctx.crap_thresholds.resolver.global.crap;
1494    let crap_effective_threshold = crap
1495        .signals
1496        .min_ceiling
1497        .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
1498
1499    FileHealthScore {
1500        path: path_owned,
1501        fan_in,
1502        fan_out,
1503        dead_code_ratio: dead_code_ratio_rounded,
1504        complexity_density: complexity_density_rounded,
1505        maintainability_index: maintainability_index_rounded,
1506        total_cyclomatic,
1507        total_cognitive,
1508        function_count,
1509        lines,
1510        crap_max: crap.max,
1511        crap_above_threshold: crap.signals.above,
1512        crap_exempted: crap.signals.exempted,
1513        crap_effective_threshold,
1514    }
1515}
1516
1517/// Compute the rounded dead-code-ratio, complexity-density, and
1518/// maintainability-index metrics for one file.
1519fn compute_file_score_metrics(
1520    node: &fallow_graph::graph::ModuleNode,
1521    path: &std::path::Path,
1522    ctx: &FileScoreLoopCtx<'_>,
1523    total_cyclomatic: u32,
1524    lines: u32,
1525    fan_out: usize,
1526) -> (f64, f64, f64) {
1527    let dead_code_ratio = compute_dead_code_ratio(
1528        path,
1529        &node.exports,
1530        ctx.unused_files,
1531        ctx.unused_exports_by_path,
1532    );
1533    let complexity_density = compute_complexity_density(total_cyclomatic, lines);
1534
1535    let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
1536    let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
1537
1538    let maintainability_index = compute_maintainability_index(
1539        complexity_density_rounded,
1540        dead_code_ratio_rounded,
1541        fan_out,
1542        lines,
1543    );
1544    (
1545        dead_code_ratio_rounded,
1546        complexity_density_rounded,
1547        (maintainability_index * 10.0).round() / 10.0,
1548    )
1549}
1550
1551fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
1552    let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
1553    let unused_deps = parts.results.unused_dependencies.len()
1554        + parts.results.unused_dev_dependencies.len()
1555        + parts.results.unused_optional_dependencies.len();
1556    let analysis_snapshot =
1557        build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
1558    let analysis_counts =
1559        build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
1560    let template_inherit_provenance =
1561        build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
1562
1563    FileScoreOutput {
1564        scores: parts.scores,
1565        coverage: parts.coverage,
1566        circular_files: parts.circular_files,
1567        top_complex_fns: parts.top_complex_fns,
1568        entry_points: parts.entry_points,
1569        value_export_counts: parts.value_export_counts,
1570        unused_export_names: parts.unused_export_names,
1571        cycle_members: parts.cycle_members,
1572        direct_callers: parts.direct_callers,
1573        analysis_counts,
1574        prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
1575        render_fan_in: parts.results.render_fan_in.clone(),
1576        analysis_snapshot,
1577        istanbul_matched: parts.istanbul_matched,
1578        istanbul_total: parts.istanbul_total,
1579        per_function_crap: parts.per_function_crap,
1580        template_inherit_provenance,
1581    }
1582}
1583
1584fn build_file_score_analysis_counts(
1585    results: &crate::results::AnalysisResults,
1586    total_exports: usize,
1587    unused_deps: usize,
1588) -> crate::vital_signs::AnalysisCounts {
1589    crate::vital_signs::AnalysisCounts {
1590        total_exports,
1591        dead_files: results.unused_files.len(),
1592        dead_exports: results.unused_exports.len() + results.unused_types.len(),
1593        unused_deps,
1594        circular_deps: results.circular_dependencies.len(),
1595        total_deps: 0usize,
1596    }
1597}
1598
1599fn build_template_inherit_provenance(
1600    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1601    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1602) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
1603    template_inherit
1604        .into_iter()
1605        .filter_map(|(file_id, ctx)| {
1606            file_paths
1607                .get(&file_id)
1608                .map(|path| ((**path).clone(), ctx.provenance_owner))
1609        })
1610        .collect()
1611}
1612
1613fn record_entry_point(
1614    entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
1615    node: &fallow_graph::graph::ModuleNode,
1616    path: &std::path::Path,
1617) {
1618    if node.is_entry_point() {
1619        entry_points.insert(path.to_path_buf());
1620    }
1621}
1622
1623fn record_unused_file_export_names(
1624    path: &std::path::Path,
1625    exports: &[fallow_graph::graph::ExportSymbol],
1626    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
1627    unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1628) {
1629    if !unused_files.contains(path) || unused_export_names.contains_key(path) {
1630        return;
1631    }
1632
1633    let names: Vec<String> = exports
1634        .iter()
1635        .filter(|export| !export.is_type_only)
1636        .map(|export| export.name.to_string())
1637        .collect();
1638    if !names.is_empty() {
1639        unused_export_names.insert(path.to_path_buf(), names);
1640    }
1641}
1642
1643struct FileScoreCrap {
1644    max: f64,
1645    signals: CrapThresholdSignals,
1646    per_function: Vec<PerFunctionCrap>,
1647    istanbul_matched: usize,
1648    istanbul_total: usize,
1649}
1650
1651impl FileScoreCrap {
1652    fn empty() -> Self {
1653        Self {
1654            max: 0.0,
1655            signals: CrapThresholdSignals::default(),
1656            per_function: Vec::new(),
1657            istanbul_matched: 0,
1658            istanbul_total: 0,
1659        }
1660    }
1661
1662    fn estimated(result: EstimatedCrapResult) -> Self {
1663        Self {
1664            max: result.max_crap,
1665            signals: result.signals,
1666            per_function: result.per_function,
1667            istanbul_matched: 0,
1668            istanbul_total: 0,
1669        }
1670    }
1671
1672    fn istanbul(result: IstanbulCrapResult) -> Self {
1673        Self {
1674            max: result.max_crap,
1675            signals: result.signals,
1676            per_function: result.per_function,
1677            istanbul_matched: result.matched,
1678            istanbul_total: result.total,
1679        }
1680    }
1681}
1682
1683fn compute_file_score_crap(
1684    node: &fallow_graph::graph::ModuleNode,
1685    ctx: &FileScoreLoopCtx<'_>,
1686    path: &std::path::Path,
1687    ceilings: &CrapCeilingLookup<'_>,
1688) -> FileScoreCrap {
1689    let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
1690        return FileScoreCrap::empty();
1691    };
1692
1693    let is_coverage_suppressed = crate::suppress::is_file_suppressed(
1694        &module.suppressions,
1695        fallow_types::suppress::IssueKind::CoverageGaps,
1696    );
1697    let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
1698    let resolution = resolve_crap_coverage(
1699        ctx.template_inherit.get(&node.file_id),
1700        ctx.istanbul_coverage,
1701        path,
1702    );
1703    match resolution {
1704        CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
1705            compute_template_inherited_crap(module, inherit_ctx, ceilings)
1706        }
1707        CrapCoverageResolution::Istanbul { file_coverage } => {
1708            compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
1709        }
1710        CrapCoverageResolution::StaticEstimated => compute_static_file_crap(
1711            module,
1712            &node.exports,
1713            ctx.test_coverage,
1714            is_test_reachable,
1715            ceilings,
1716        ),
1717    }
1718}
1719
1720fn compute_template_inherited_crap(
1721    module: &crate::source::ModuleInfo,
1722    inherit_ctx: &TemplateInheritContext,
1723    ceilings: &CrapCeilingLookup<'_>,
1724) -> FileScoreCrap {
1725    FileScoreCrap::estimated(compute_crap_scores_estimated(
1726        &module.complexity,
1727        &inherit_ctx.test_referenced_exports,
1728        inherit_ctx.is_test_reachable,
1729        fallow_output::CoverageSource::EstimatedComponentInherited,
1730        ceilings,
1731    ))
1732}
1733
1734fn compute_istanbul_file_crap(
1735    module: &crate::source::ModuleInfo,
1736    file_coverage: Option<&IstanbulFileCoverage>,
1737    is_test_reachable: bool,
1738    ceilings: &CrapCeilingLookup<'_>,
1739) -> FileScoreCrap {
1740    FileScoreCrap::istanbul(compute_crap_scores_istanbul(
1741        &module.complexity,
1742        file_coverage,
1743        is_test_reachable,
1744        ceilings,
1745    ))
1746}
1747
1748fn compute_static_file_crap(
1749    module: &crate::source::ModuleInfo,
1750    exports: &[fallow_graph::graph::ExportSymbol],
1751    test_coverage: StaticTestCoverage<'_>,
1752    is_test_reachable: bool,
1753    ceilings: &CrapCeilingLookup<'_>,
1754) -> FileScoreCrap {
1755    let test_refs = build_test_referenced_exports(exports, test_coverage);
1756    FileScoreCrap::estimated(compute_crap_scores_estimated(
1757        &module.complexity,
1758        &test_refs,
1759        is_test_reachable,
1760        fallow_output::CoverageSource::Estimated,
1761        ceilings,
1762    ))
1763}
1764
1765fn record_per_function_crap(
1766    per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1767    path: &std::path::Path,
1768    per_function: Vec<PerFunctionCrap>,
1769) {
1770    if !per_function.is_empty() {
1771        per_function_crap.insert(path.to_path_buf(), per_function);
1772    }
1773}
1774
1775struct FileScoreCoverageSetup<'a> {
1776    module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1777    coverage: CoverageGapData,
1778}
1779
1780fn prepare_file_score_coverage_setup<'a>(
1781    modules: &'a [crate::source::ModuleInfo],
1782    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1783    results: &crate::results::AnalysisResults,
1784    graph: &fallow_graph::graph::ModuleGraph,
1785    test_coverage: StaticTestCoverage<'_>,
1786    root: &std::path::Path,
1787) -> FileScoreCoverageSetup<'a> {
1788    let module_by_id: rustc_hash::FxHashMap<_, _> =
1789        modules.iter().map(|m| (m.file_id, m)).collect();
1790    let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
1791        .unused_exports
1792        .iter()
1793        .map(|export| {
1794            (
1795                export.export.path.as_path(),
1796                export.export.export_name.clone(),
1797            )
1798        })
1799        .collect();
1800    let coverage = compute_coverage_gaps(
1801        graph,
1802        test_coverage,
1803        file_paths,
1804        &module_by_id,
1805        &unused_exports,
1806        root,
1807    );
1808    FileScoreCoverageSetup {
1809        module_by_id,
1810        coverage,
1811    }
1812}
1813
1814fn collect_circular_files(
1815    results: &crate::results::AnalysisResults,
1816) -> rustc_hash::FxHashSet<std::path::PathBuf> {
1817    results
1818        .circular_dependencies
1819        .iter()
1820        .flat_map(|c| c.cycle.files.iter().cloned())
1821        .collect()
1822}
1823
1824fn collect_top_complex_fns(
1825    modules: &[crate::source::ModuleInfo],
1826    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1827) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
1828    let mut top_complex_fns = rustc_hash::FxHashMap::default();
1829    for module in modules {
1830        if module.complexity.is_empty() {
1831            continue;
1832        }
1833        let Some(path) = file_paths.get(&module.file_id) else {
1834            continue;
1835        };
1836        let mut funcs: Vec<(String, u32, u16)> = module
1837            .complexity
1838            .iter()
1839            .map(|f| (f.name.clone(), f.line, f.cognitive))
1840            .collect();
1841        funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
1842        funcs.truncate(3);
1843        if funcs[0].2 > 0 {
1844            top_complex_fns.insert((*path).clone(), funcs);
1845        }
1846    }
1847    top_complex_fns
1848}
1849
1850fn collect_cycle_members(
1851    results: &crate::results::AnalysisResults,
1852) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
1853    let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
1854        rustc_hash::FxHashMap::default();
1855    for cycle in &results.circular_dependencies {
1856        for file in &cycle.cycle.files {
1857            let others: Vec<std::path::PathBuf> = cycle
1858                .cycle
1859                .files
1860                .iter()
1861                .filter(|f| *f != file)
1862                .cloned()
1863                .collect();
1864            cycle_members
1865                .entry(file.clone())
1866                .or_default()
1867                .extend(others);
1868        }
1869    }
1870    for members in cycle_members.values_mut() {
1871        members.sort();
1872        members.dedup();
1873    }
1874    cycle_members
1875}
1876
1877fn collect_unused_export_names(
1878    results: &crate::results::AnalysisResults,
1879) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
1880    let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
1881        rustc_hash::FxHashMap::default();
1882    for exp in &results.unused_exports {
1883        unused_export_names
1884            .entry(exp.export.path.clone())
1885            .or_default()
1886            .push(exp.export.export_name.clone());
1887    }
1888    unused_export_names
1889}
1890
1891fn build_analysis_counts_snapshot(
1892    graph: &fallow_graph::graph::ModuleGraph,
1893    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1894    results: &crate::results::AnalysisResults,
1895    unused_deps: usize,
1896) -> AnalysisCountsSnapshot {
1897    let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
1898        graph.modules.len(),
1899        rustc_hash::FxBuildHasher,
1900    );
1901    for module in &graph.modules {
1902        if let Some(path) = file_paths.get(&module.file_id) {
1903            module_export_counts.insert((*path).clone(), module.exports.len());
1904        }
1905    }
1906
1907    let mut unused_export_paths =
1908        Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
1909    unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
1910    unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
1911
1912    let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
1913    unused_dep_package_paths.extend(
1914        results
1915            .unused_dependencies
1916            .iter()
1917            .map(|d| d.dep.path.clone()),
1918    );
1919    unused_dep_package_paths.extend(
1920        results
1921            .unused_dev_dependencies
1922            .iter()
1923            .map(|d| d.dep.path.clone()),
1924    );
1925    unused_dep_package_paths.extend(
1926        results
1927            .unused_optional_dependencies
1928            .iter()
1929            .map(|d| d.dep.path.clone()),
1930    );
1931
1932    AnalysisCountsSnapshot {
1933        unused_file_paths: results
1934            .unused_files
1935            .iter()
1936            .map(|f| f.file.path.clone())
1937            .collect(),
1938        unused_export_paths,
1939        unused_dep_package_paths,
1940        circular_dep_groups: results
1941            .circular_dependencies
1942            .iter()
1943            .map(|c| c.cycle.files.clone())
1944            .collect(),
1945        module_export_counts,
1946    }
1947}
1948
1949#[cfg(test)]
1950mod tests {
1951    use super::super::threshold_overrides::GlobalHealthThresholds;
1952    use super::*;
1953
1954    /// Resolver with no override entries and the given global CRAP ceiling,
1955    /// the default-configuration shape for scoring tests.
1956    fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
1957        ThresholdOverrideResolver::new(
1958            &[],
1959            GlobalHealthThresholds {
1960                cyclomatic: 20,
1961                cognitive: 15,
1962                crap,
1963                unit_size: 120,
1964            },
1965        )
1966    }
1967
1968    /// Resolver with the given override entries over the default global 30.0.
1969    fn test_override_resolver(
1970        overrides: &[fallow_config::HealthThresholdOverride],
1971    ) -> ThresholdOverrideResolver {
1972        ThresholdOverrideResolver::new(
1973            overrides,
1974            GlobalHealthThresholds {
1975                cyclomatic: 20,
1976                cognitive: 15,
1977                crap: CRAP_THRESHOLD,
1978                unit_size: 120,
1979            },
1980        )
1981    }
1982
1983    /// `compute_crap_scores_istanbul` with default-configuration ceilings.
1984    fn istanbul_crap_default(
1985        complexity: &[fallow_types::extract::FunctionComplexity],
1986        file_coverage: Option<&IstanbulFileCoverage>,
1987        is_test_reachable: bool,
1988    ) -> IstanbulCrapResult {
1989        let resolver = test_crap_resolver(CRAP_THRESHOLD);
1990        let ceilings = CrapCeilingLookup::new(
1991            CrapScoreThresholds {
1992                resolver: &resolver,
1993                enforce_crap: true,
1994            },
1995            std::path::Path::new("src/test.ts"),
1996        );
1997        compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
1998    }
1999
2000    /// `compute_crap_scores_estimated` with default-configuration ceilings.
2001    fn estimated_crap_default(
2002        complexity: &[fallow_types::extract::FunctionComplexity],
2003        test_referenced_exports: &rustc_hash::FxHashSet<String>,
2004        is_test_reachable: bool,
2005        coverage_source: fallow_output::CoverageSource,
2006    ) -> EstimatedCrapResult {
2007        let resolver = test_crap_resolver(CRAP_THRESHOLD);
2008        let ceilings = CrapCeilingLookup::new(
2009            CrapScoreThresholds {
2010                resolver: &resolver,
2011                enforce_crap: true,
2012            },
2013            std::path::Path::new("src/test.ts"),
2014        );
2015        compute_crap_scores_estimated(
2016            complexity,
2017            test_referenced_exports,
2018            is_test_reachable,
2019            coverage_source,
2020            &ceilings,
2021        )
2022    }
2023
2024    /// `compute_file_scores` with default-configuration CRAP thresholds.
2025    fn compute_file_scores_default(
2026        modules: &[crate::source::ModuleInfo],
2027        file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2028        changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2029        analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2030        istanbul_coverage: Option<&IstanbulCoverage>,
2031        root: &std::path::Path,
2032    ) -> Result<FileScoreOutput, String> {
2033        let resolver = test_crap_resolver(CRAP_THRESHOLD);
2034        compute_file_scores(
2035            FileScoreComputeInput {
2036                modules,
2037                file_paths,
2038                changed_files,
2039                istanbul_coverage,
2040                root,
2041                crap_thresholds: CrapScoreThresholds {
2042                    resolver: &resolver,
2043                    enforce_crap: true,
2044                },
2045            },
2046            analysis_output,
2047        )
2048    }
2049
2050    #[test]
2051    fn maintainability_perfect_score() {
2052        assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
2053    }
2054
2055    #[test]
2056    fn crap_resolution_prefers_template_inheritance_over_istanbul() {
2057        let inherit_ctx = TemplateInheritContext {
2058            is_test_reachable: true,
2059            test_referenced_exports: rustc_hash::FxHashSet::default(),
2060            provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
2061        };
2062        let istanbul = IstanbulCoverage {
2063            files: rustc_hash::FxHashMap::default(),
2064        };
2065
2066        let resolution = resolve_crap_coverage(
2067            Some(&inherit_ctx),
2068            Some(&istanbul),
2069            std::path::Path::new("/project/src/app.component.html"),
2070        );
2071
2072        assert!(matches!(
2073            resolution,
2074            CrapCoverageResolution::TemplateInherited(_)
2075        ));
2076    }
2077
2078    #[test]
2079    fn crap_resolution_keeps_istanbul_when_file_is_missing() {
2080        let istanbul = IstanbulCoverage {
2081            files: rustc_hash::FxHashMap::default(),
2082        };
2083
2084        let resolution = resolve_crap_coverage(
2085            None,
2086            Some(&istanbul),
2087            std::path::Path::new("/project/src/missing.ts"),
2088        );
2089
2090        assert!(matches!(
2091            resolution,
2092            CrapCoverageResolution::Istanbul {
2093                file_coverage: None
2094            }
2095        ));
2096    }
2097
2098    #[test]
2099    fn maintainability_clamped_at_zero() {
2100        assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
2101    }
2102
2103    #[test]
2104    fn maintainability_formula_correct() {
2105        let result = compute_maintainability_index(0.5, 0.3, 10, 100);
2106        let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
2107        assert!((result - expected).abs() < 0.01);
2108    }
2109
2110    #[test]
2111    fn maintainability_dead_file_penalty() {
2112        let result = compute_maintainability_index(0.0, 1.0, 0, 100);
2113        assert!((result - 80.0).abs() < f64::EPSILON);
2114    }
2115
2116    #[test]
2117    fn maintainability_fan_out_is_logarithmic() {
2118        let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
2119        let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
2120        let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
2121
2122        assert!(result_10 > 90.0); // ~90.4
2123        assert!(result_100 > 84.0); // 85.0 (capped)
2124        assert!((result_100 - result_200).abs() < f64::EPSILON);
2125    }
2126
2127    #[test]
2128    fn maintainability_fan_out_capped_at_15() {
2129        let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
2130        assert!((result - 65.0).abs() < f64::EPSILON);
2131    }
2132
2133    #[test]
2134    fn maintainability_small_file_dampened() {
2135        let small = compute_maintainability_index(0.40, 0.0, 0, 5);
2136        assert!((small - 98.8).abs() < 0.01);
2137    }
2138
2139    #[test]
2140    fn maintainability_large_file_undampened() {
2141        let large = compute_maintainability_index(0.30, 0.0, 0, 192);
2142        assert!((large - 91.0).abs() < 0.01);
2143    }
2144
2145    #[test]
2146    fn maintainability_small_file_ranks_better_than_complex_large_file() {
2147        let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
2148        let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
2149        assert!(
2150            trivial > nightmare,
2151            "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
2152        );
2153    }
2154
2155    #[test]
2156    fn maintainability_at_dampening_boundary() {
2157        let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
2158        let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
2159        assert!((at_boundary - above_boundary).abs() < 0.01);
2160    }
2161
2162    #[test]
2163    fn maintainability_zero_lines_zero_density_penalty() {
2164        let result = compute_maintainability_index(5.0, 0.0, 0, 0);
2165        assert!((result - 100.0).abs() < f64::EPSILON);
2166    }
2167
2168    #[test]
2169    fn complexity_density_zero_lines() {
2170        assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
2171    }
2172
2173    #[test]
2174    fn complexity_density_normal() {
2175        let result = compute_complexity_density(10, 100);
2176        assert!((result - 0.1).abs() < f64::EPSILON);
2177    }
2178
2179    #[test]
2180    fn complexity_density_high() {
2181        let result = compute_complexity_density(50, 10);
2182        assert!((result - 5.0).abs() < f64::EPSILON);
2183    }
2184
2185    #[test]
2186    fn dead_code_ratio_no_exports() {
2187        let unused_files = rustc_hash::FxHashSet::default();
2188        let unused_map = rustc_hash::FxHashMap::default();
2189        let path = std::path::Path::new("/src/foo.ts");
2190        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
2191
2192        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2193        assert!((ratio).abs() < f64::EPSILON);
2194    }
2195
2196    #[test]
2197    fn dead_code_ratio_all_unused_file() {
2198        let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
2199            rustc_hash::FxHashSet::default();
2200        let path = std::path::Path::new("/src/foo.ts");
2201        unused_files.insert(path);
2202        let unused_map = rustc_hash::FxHashMap::default();
2203        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
2204
2205        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2206        assert!((ratio - 1.0).abs() < f64::EPSILON);
2207    }
2208
2209    #[test]
2210    fn dead_code_ratio_mix() {
2211        let unused_files = rustc_hash::FxHashSet::default();
2212        let path = std::path::Path::new("/src/foo.ts");
2213
2214        let exports = vec![
2215            fallow_graph::graph::ExportSymbol {
2216                name: crate::source::ExportName::Named("a".into()),
2217                is_type_only: false,
2218                is_side_effect_used: false,
2219                visibility: crate::source::VisibilityTag::None,
2220                expected_unused_reason: None,
2221                span: oxc_span::Span::empty(0),
2222                references: vec![],
2223                reference_paths: Vec::new(),
2224                members: vec![],
2225            },
2226            fallow_graph::graph::ExportSymbol {
2227                name: crate::source::ExportName::Named("b".into()),
2228                is_type_only: false,
2229                is_side_effect_used: false,
2230                visibility: crate::source::VisibilityTag::None,
2231                expected_unused_reason: None,
2232                span: oxc_span::Span::empty(0),
2233                references: vec![],
2234                reference_paths: Vec::new(),
2235                members: vec![],
2236            },
2237            fallow_graph::graph::ExportSymbol {
2238                name: crate::source::ExportName::Named("c".into()),
2239                is_type_only: false,
2240                is_side_effect_used: false,
2241                visibility: crate::source::VisibilityTag::None,
2242                expected_unused_reason: None,
2243                span: oxc_span::Span::empty(0),
2244                references: vec![],
2245                reference_paths: Vec::new(),
2246                members: vec![],
2247            },
2248            fallow_graph::graph::ExportSymbol {
2249                name: crate::source::ExportName::Named("MyType".into()),
2250                is_type_only: true,
2251                is_side_effect_used: false,
2252                visibility: crate::source::VisibilityTag::None,
2253                expected_unused_reason: None,
2254                span: oxc_span::Span::empty(0),
2255                references: vec![],
2256                reference_paths: Vec::new(),
2257                members: vec![],
2258            },
2259        ];
2260
2261        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2262            rustc_hash::FxHashMap::default();
2263        unused_map.insert(path, 2);
2264
2265        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2266        assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
2267    }
2268
2269    #[test]
2270    fn dead_code_ratio_all_type_only_exports() {
2271        let unused_files = rustc_hash::FxHashSet::default();
2272        let path = std::path::Path::new("/src/types.ts");
2273
2274        let exports = vec![fallow_graph::graph::ExportSymbol {
2275            name: crate::source::ExportName::Named("Foo".into()),
2276            is_type_only: true,
2277            is_side_effect_used: false,
2278            visibility: crate::source::VisibilityTag::None,
2279            expected_unused_reason: None,
2280            span: oxc_span::Span::empty(0),
2281            references: vec![],
2282            reference_paths: Vec::new(),
2283            members: vec![],
2284        }];
2285        let unused_map = rustc_hash::FxHashMap::default();
2286
2287        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2288        assert!((ratio).abs() < f64::EPSILON);
2289    }
2290
2291    #[test]
2292    fn aggregate_complexity_empty_module() {
2293        let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
2294
2295        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2296        assert_eq!(cyc, 0);
2297        assert_eq!(cog, 0);
2298        assert_eq!(funcs, 0);
2299        assert_eq!(lines, 0);
2300    }
2301
2302    #[test]
2303    fn aggregate_complexity_single_function() {
2304        let module = crate::source::ModuleInfo {
2305            line_offsets: vec![0, 10, 20, 30, 40], // 5 lines
2306            complexity: vec![fallow_types::extract::FunctionComplexity {
2307                name: "doStuff".into(),
2308                line: 1,
2309                col: 0,
2310                cyclomatic: 7,
2311                cognitive: 4,
2312                line_count: 5,
2313                param_count: 0,
2314                react_hook_count: 0,
2315                react_jsx_max_depth: 0,
2316                react_prop_count: 0,
2317                source_hash: None,
2318                contributions: Vec::new(),
2319            }],
2320            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
2321        };
2322
2323        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2324        assert_eq!(cyc, 7);
2325        assert_eq!(cog, 4);
2326        assert_eq!(funcs, 1);
2327        assert_eq!(lines, 5);
2328    }
2329
2330    #[test]
2331    fn aggregate_complexity_multiple_functions() {
2332        let module = crate::source::ModuleInfo {
2333            line_offsets: vec![0, 10, 20], // 3 lines
2334            complexity: vec![
2335                fallow_types::extract::FunctionComplexity {
2336                    name: "a".into(),
2337                    line: 1,
2338                    col: 0,
2339                    cyclomatic: 3,
2340                    cognitive: 2,
2341                    line_count: 1,
2342                    param_count: 0,
2343                    react_hook_count: 0,
2344                    react_jsx_max_depth: 0,
2345                    react_prop_count: 0,
2346                    source_hash: None,
2347                    contributions: Vec::new(),
2348                },
2349                fallow_types::extract::FunctionComplexity {
2350                    name: "b".into(),
2351                    line: 2,
2352                    col: 0,
2353                    cyclomatic: 5,
2354                    cognitive: 8,
2355                    line_count: 2,
2356                    param_count: 0,
2357                    react_hook_count: 0,
2358                    react_jsx_max_depth: 0,
2359                    react_prop_count: 0,
2360                    source_hash: None,
2361                    contributions: Vec::new(),
2362                },
2363            ],
2364            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
2365        };
2366
2367        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2368        assert_eq!(cyc, 8);
2369        assert_eq!(cog, 10);
2370        assert_eq!(funcs, 2);
2371        assert_eq!(lines, 3);
2372    }
2373
2374    #[test]
2375    fn count_unused_exports_empty() {
2376        let exports: Vec<crate::results::UnusedExportFinding> = vec![];
2377        let map = count_unused_exports_by_path(&exports);
2378        assert!(map.is_empty());
2379    }
2380
2381    #[test]
2382    fn count_unused_exports_groups_by_path() {
2383        let exports = vec![
2384            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2385                path: std::path::PathBuf::from("/src/a.ts"),
2386                export_name: "foo".into(),
2387                is_type_only: false,
2388                line: 1,
2389                col: 0,
2390                span_start: 0,
2391                is_re_export: false,
2392            }),
2393            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2394                path: std::path::PathBuf::from("/src/a.ts"),
2395                export_name: "bar".into(),
2396                is_type_only: false,
2397                line: 5,
2398                col: 0,
2399                span_start: 40,
2400                is_re_export: false,
2401            }),
2402            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2403                path: std::path::PathBuf::from("/src/b.ts"),
2404                export_name: "baz".into(),
2405                is_type_only: false,
2406                line: 1,
2407                col: 0,
2408                span_start: 0,
2409                is_re_export: false,
2410            }),
2411        ];
2412        let map = count_unused_exports_by_path(&exports);
2413        assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
2414        assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
2415    }
2416
2417    #[test]
2418    fn dead_code_ratio_all_value_exports_unused() {
2419        let unused_files = rustc_hash::FxHashSet::default();
2420        let path = std::path::Path::new("/src/foo.ts");
2421
2422        let exports = vec![
2423            fallow_graph::graph::ExportSymbol {
2424                name: crate::source::ExportName::Named("a".into()),
2425                is_type_only: false,
2426                is_side_effect_used: false,
2427                visibility: crate::source::VisibilityTag::None,
2428                expected_unused_reason: None,
2429                span: oxc_span::Span::empty(0),
2430                references: vec![],
2431                reference_paths: Vec::new(),
2432                members: vec![],
2433            },
2434            fallow_graph::graph::ExportSymbol {
2435                name: crate::source::ExportName::Named("b".into()),
2436                is_type_only: false,
2437                is_side_effect_used: false,
2438                visibility: crate::source::VisibilityTag::None,
2439                expected_unused_reason: None,
2440                span: oxc_span::Span::empty(0),
2441                references: vec![],
2442                reference_paths: Vec::new(),
2443                members: vec![],
2444            },
2445        ];
2446
2447        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2448            rustc_hash::FxHashMap::default();
2449        unused_map.insert(path, 2);
2450
2451        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2452        assert!((ratio - 1.0).abs() < f64::EPSILON);
2453    }
2454
2455    #[test]
2456    fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
2457        let unused_files = rustc_hash::FxHashSet::default();
2458        let path = std::path::Path::new("/src/foo.ts");
2459
2460        let exports = vec![fallow_graph::graph::ExportSymbol {
2461            name: crate::source::ExportName::Named("a".into()),
2462            is_type_only: false,
2463            is_side_effect_used: false,
2464            visibility: crate::source::VisibilityTag::None,
2465            expected_unused_reason: None,
2466            span: oxc_span::Span::empty(0),
2467            references: vec![],
2468            reference_paths: Vec::new(),
2469            members: vec![],
2470        }];
2471
2472        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2473            rustc_hash::FxHashMap::default();
2474        unused_map.insert(path, 5);
2475
2476        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2477        assert!((ratio - 1.0).abs() < f64::EPSILON);
2478    }
2479
2480    #[test]
2481    fn dead_code_ratio_no_unused_exports_for_path() {
2482        let unused_files = rustc_hash::FxHashSet::default();
2483        let path = std::path::Path::new("/src/clean.ts");
2484
2485        let exports = vec![fallow_graph::graph::ExportSymbol {
2486            name: crate::source::ExportName::Named("used".into()),
2487            is_type_only: false,
2488            is_side_effect_used: false,
2489            visibility: crate::source::VisibilityTag::None,
2490            expected_unused_reason: None,
2491            span: oxc_span::Span::empty(0),
2492            references: vec![],
2493            reference_paths: Vec::new(),
2494            members: vec![],
2495        }];
2496
2497        let unused_map = rustc_hash::FxHashMap::default();
2498        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2499        assert!(ratio.abs() < f64::EPSILON);
2500    }
2501
2502    #[test]
2503    fn complexity_density_zero_cyclomatic_with_lines() {
2504        let result = compute_complexity_density(0, 100);
2505        assert!(result.abs() < f64::EPSILON);
2506    }
2507
2508    #[test]
2509    fn complexity_density_single_line() {
2510        let result = compute_complexity_density(1, 1);
2511        assert!((result - 1.0).abs() < f64::EPSILON);
2512    }
2513
2514    #[test]
2515    fn maintainability_only_complexity_penalty() {
2516        let result = compute_maintainability_index(3.0, 0.0, 0, 100);
2517        assert!((result - 10.0).abs() < f64::EPSILON);
2518    }
2519
2520    #[test]
2521    fn maintainability_only_dead_code_penalty() {
2522        let result = compute_maintainability_index(0.0, 0.5, 0, 100);
2523        assert!((result - 90.0).abs() < f64::EPSILON);
2524    }
2525
2526    #[test]
2527    fn maintainability_fan_out_one() {
2528        let result = compute_maintainability_index(0.0, 0.0, 1, 100);
2529        let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
2530        assert!((result - expected).abs() < 0.01);
2531    }
2532
2533    #[test]
2534    fn maintainability_all_penalties_maxed() {
2535        let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
2536        assert!(result.abs() < f64::EPSILON);
2537    }
2538
2539    #[test]
2540    fn count_unused_exports_single_file_single_export() {
2541        let exports = vec![crate::results::UnusedExportFinding::with_actions(
2542            crate::results::UnusedExport {
2543                path: std::path::PathBuf::from("/src/only.ts"),
2544                export_name: "lonely".into(),
2545                is_type_only: false,
2546                line: 1,
2547                col: 0,
2548                span_start: 0,
2549                is_re_export: false,
2550            },
2551        )];
2552        let map = count_unused_exports_by_path(&exports);
2553        assert_eq!(map.len(), 1);
2554        assert_eq!(
2555            map.get(std::path::Path::new("/src/only.ts")).copied(),
2556            Some(1)
2557        );
2558    }
2559
2560    /// Helper to build a minimal `ModuleGraph` from scratch.
2561    fn build_test_graph(
2562        files: &[crate::discover::DiscoveredFile],
2563        entry_point_paths: &[std::path::PathBuf],
2564        resolved_modules: &[fallow_graph::resolve::ResolvedModule],
2565    ) -> fallow_graph::graph::ModuleGraph {
2566        let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
2567            .iter()
2568            .map(|p| crate::discover::EntryPoint {
2569                path: p.clone(),
2570                source: crate::discover::EntryPointSource::PackageJsonMain,
2571            })
2572            .collect();
2573        fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
2574    }
2575
2576    /// Helper to create a `ModuleInfo` with given complexity and line count.
2577    fn make_module_info(
2578        file_id: u32,
2579        line_count: usize,
2580        functions: Vec<fallow_types::extract::FunctionComplexity>,
2581    ) -> crate::source::ModuleInfo {
2582        crate::source::ModuleInfo {
2583            line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
2584            complexity: functions,
2585            ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
2586        }
2587    }
2588
2589    fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
2590        FileHealthScore {
2591            path: std::path::PathBuf::from(path),
2592            fan_in: 0,
2593            fan_out: 0,
2594            dead_code_ratio: 0.0,
2595            complexity_density: 0.0,
2596            maintainability_index,
2597            total_cyclomatic: 0,
2598            total_cognitive: 0,
2599            function_count: 1,
2600            lines: 1,
2601            crap_max,
2602            crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
2603            crap_exempted: 0,
2604            crap_effective_threshold: None,
2605        }
2606    }
2607
2608    fn crap_concern_at_default(crap_max: f64) -> f64 {
2609        file_score_crap_concern(
2610            &make_file_score("/src/concern.ts", 100.0, crap_max),
2611            CRAP_THRESHOLD,
2612        )
2613    }
2614
2615    #[test]
2616    fn file_score_crap_concern_tracks_crap_risk_bands() {
2617        assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
2618        assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
2619        assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
2620        assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
2621        assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
2622    }
2623
2624    #[test]
2625    fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
2626        // At t = 500 the band edges scale to (250, 500, 1666.7): a breaching
2627        // 250 sits at the moderate/high edge and a breaching 500 at high.
2628        let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
2629        at_edge.crap_above_threshold = 1;
2630        at_edge.crap_effective_threshold = Some(500.0);
2631        assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
2632
2633        let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
2634        at_ceiling.crap_above_threshold = 1;
2635        at_ceiling.crap_effective_threshold = Some(500.0);
2636        assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
2637    }
2638
2639    #[test]
2640    fn file_score_crap_concern_zeroes_fully_exempt_file() {
2641        // The issue's own numbers: crap_max 110 under ceiling 500 with both
2642        // breaches exempted. Band generalization alone would yield 19.8 and
2643        // keep the row above a structural concern of 12; the fully-exempt rule
2644        // must zero it (issue #2228).
2645        let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
2646        exempt.crap_above_threshold = 0;
2647        exempt.crap_exempted = 2;
2648        exempt.crap_effective_threshold = Some(500.0);
2649        assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
2650        assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
2651        assert_eq!(
2652            file_score_concern_axis(&exempt, CRAP_THRESHOLD),
2653            FileScoreConcern::Structural
2654        );
2655    }
2656
2657    #[test]
2658    fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
2659        let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
2660        score.crap_above_threshold = 0;
2661        score.crap_exempted = 2;
2662        assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
2663        assert!(file_score_fully_crap_exempt(&score, 0.0));
2664        assert_eq!(
2665            file_score_concern_axis(&score, 0.0),
2666            FileScoreConcern::Structural
2667        );
2668    }
2669
2670    #[test]
2671    fn file_score_partial_exemption_keeps_risk_axis() {
2672        // One breaching function survives its ceiling: the row is NOT fully
2673        // exempt, so the risk story stays.
2674        let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
2675        mixed.crap_above_threshold = 1;
2676        mixed.crap_exempted = 1;
2677        mixed.crap_effective_threshold = Some(30.0);
2678        assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
2679        assert_eq!(
2680            file_score_concern_axis(&mixed, CRAP_THRESHOLD),
2681            FileScoreConcern::Risk
2682        );
2683    }
2684
2685    #[test]
2686    fn file_score_concern_axis_labels_dominant_signal() {
2687        let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
2688        assert_eq!(
2689            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
2690            FileScoreConcern::Risk
2691        );
2692        assert_eq!(
2693            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
2694            "risk"
2695        );
2696
2697        let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
2698        assert_eq!(
2699            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
2700            FileScoreConcern::Structural
2701        );
2702        assert_eq!(
2703            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
2704            "structure"
2705        );
2706
2707        let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
2708        assert_eq!(
2709            file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
2710            FileScoreConcern::Structural
2711        );
2712    }
2713
2714    #[test]
2715    fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
2716        let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
2717        let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
2718
2719        let mut scores = [low_mi_low_risk, higher_mi_high_risk];
2720        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
2721
2722        assert_eq!(
2723            scores[0].path,
2724            std::path::Path::new("/src/higher-mi-high-risk.ts")
2725        );
2726        assert_eq!(
2727            scores[1].path,
2728            std::path::Path::new("/src/low-mi-low-risk.ts")
2729        );
2730    }
2731
2732    #[test]
2733    fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
2734        let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
2735        let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
2736
2737        let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
2738        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
2739
2740        assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
2741        assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
2742    }
2743
2744    #[test]
2745    fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
2746        let mut scores = [
2747            make_file_score("/src/b.ts", 70.0, 1.0),
2748            make_file_score("/src/a.ts", 70.0, 1.0),
2749            make_file_score("/src/higher-crap.ts", 70.0, 2.0),
2750            make_file_score("/src/lower-concern.ts", 80.0, 1.0),
2751        ];
2752
2753        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
2754
2755        let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
2756        assert_eq!(
2757            paths,
2758            vec![
2759                std::path::Path::new("/src/higher-crap.ts"),
2760                std::path::Path::new("/src/a.ts"),
2761                std::path::Path::new("/src/b.ts"),
2762                std::path::Path::new("/src/lower-concern.ts"),
2763            ]
2764        );
2765    }
2766
2767    #[test]
2768    fn compute_file_scores_empty_graph() {
2769        let files: Vec<crate::discover::DiscoveredFile> = vec![];
2770        let graph = build_test_graph(&files, &[], &[]);
2771        let modules: Vec<crate::source::ModuleInfo> = vec![];
2772        let file_paths = rustc_hash::FxHashMap::default();
2773
2774        let output = crate::results::DeadCodeAnalysisArtifacts {
2775            results: fallow_types::results::AnalysisResults::default(),
2776            timings: None,
2777            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2778            modules: None,
2779            files: None,
2780            script_used_packages: rustc_hash::FxHashSet::default(),
2781            file_hashes: rustc_hash::FxHashMap::default(),
2782        };
2783
2784        let result = compute_file_scores_default(
2785            &modules,
2786            &file_paths,
2787            None,
2788            output,
2789            None,
2790            std::path::Path::new("/project"),
2791        )
2792        .unwrap();
2793        assert!(result.scores.is_empty());
2794        assert!(result.circular_files.is_empty());
2795        assert!(result.top_complex_fns.is_empty());
2796        assert!(result.entry_points.is_empty());
2797        assert_eq!(result.analysis_counts.total_exports, 0);
2798        assert_eq!(result.analysis_counts.dead_files, 0);
2799    }
2800
2801    #[test]
2802    fn compute_file_scores_no_graph_returns_error() {
2803        let modules: Vec<crate::source::ModuleInfo> = vec![];
2804        let file_paths = rustc_hash::FxHashMap::default();
2805
2806        let output = crate::results::DeadCodeAnalysisArtifacts {
2807            results: fallow_types::results::AnalysisResults::default(),
2808            timings: None,
2809            graph: None,
2810            modules: None,
2811            files: None,
2812            script_used_packages: rustc_hash::FxHashSet::default(),
2813            file_hashes: rustc_hash::FxHashMap::default(),
2814        };
2815
2816        let result = compute_file_scores_default(
2817            &modules,
2818            &file_paths,
2819            None,
2820            output,
2821            None,
2822            std::path::Path::new("/project"),
2823        );
2824        assert!(result.is_err());
2825        match result {
2826            Err(msg) => assert_eq!(msg, "graph not available"),
2827            Ok(_) => panic!("expected error"),
2828        }
2829    }
2830
2831    #[test]
2832    fn compute_file_scores_single_file_with_function() {
2833        let path_a = std::path::PathBuf::from("/src/a.ts");
2834        let files = vec![crate::discover::DiscoveredFile {
2835            id: crate::discover::FileId(0),
2836            path: path_a.clone(),
2837            size_bytes: 100,
2838        }];
2839
2840        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2841            file_id: crate::discover::FileId(0),
2842            path: path_a.clone(),
2843            exports: vec![fallow_types::extract::ExportInfo {
2844                name: crate::source::ExportName::Named("foo".into()),
2845                local_name: None,
2846                is_type_only: false,
2847                visibility: crate::source::VisibilityTag::None,
2848                expected_unused_reason: None,
2849                span: oxc_span::Span::empty(0),
2850                members: vec![],
2851                is_side_effect_used: false,
2852                super_class: None,
2853            }]
2854            .into(),
2855            ..Default::default()
2856        }];
2857
2858        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2859
2860        let modules = vec![make_module_info(
2861            0,
2862            10,
2863            vec![fallow_types::extract::FunctionComplexity {
2864                name: "foo".into(),
2865                line: 1,
2866                col: 0,
2867                cyclomatic: 5,
2868                cognitive: 3,
2869                line_count: 10,
2870                param_count: 0,
2871                react_hook_count: 0,
2872                react_jsx_max_depth: 0,
2873                react_prop_count: 0,
2874                source_hash: None,
2875                contributions: Vec::new(),
2876            }],
2877        )];
2878
2879        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2880            rustc_hash::FxHashMap::default();
2881        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2882
2883        let output = crate::results::DeadCodeAnalysisArtifacts {
2884            results: fallow_types::results::AnalysisResults::default(),
2885            timings: None,
2886            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2887            modules: None,
2888            files: None,
2889            script_used_packages: rustc_hash::FxHashSet::default(),
2890            file_hashes: rustc_hash::FxHashMap::default(),
2891        };
2892
2893        let result = compute_file_scores_default(
2894            &modules,
2895            &file_paths,
2896            None,
2897            output,
2898            None,
2899            std::path::Path::new("/project"),
2900        )
2901        .unwrap();
2902        assert_eq!(result.scores.len(), 1);
2903
2904        let score = &result.scores[0];
2905        assert_eq!(score.path, path_a);
2906        assert_eq!(score.total_cyclomatic, 5);
2907        assert_eq!(score.total_cognitive, 3);
2908        assert_eq!(score.function_count, 1);
2909        assert_eq!(score.lines, 10);
2910        assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
2911        assert!(score.dead_code_ratio.abs() < f64::EPSILON);
2912        assert!(result.entry_points.contains(&path_a));
2913    }
2914
2915    #[test]
2916    fn compute_file_scores_excludes_barrel_files() {
2917        let path_a = std::path::PathBuf::from("/src/index.ts");
2918        let files = vec![crate::discover::DiscoveredFile {
2919            id: crate::discover::FileId(0),
2920            path: path_a.clone(),
2921            size_bytes: 50,
2922        }];
2923
2924        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2925            file_id: crate::discover::FileId(0),
2926            path: path_a.clone(),
2927            ..Default::default()
2928        }];
2929
2930        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2931
2932        let modules = vec![make_module_info(0, 5, vec![])];
2933
2934        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2935            rustc_hash::FxHashMap::default();
2936        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2937
2938        let output = crate::results::DeadCodeAnalysisArtifacts {
2939            results: fallow_types::results::AnalysisResults::default(),
2940            timings: None,
2941            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2942            modules: None,
2943            files: None,
2944            script_used_packages: rustc_hash::FxHashSet::default(),
2945            file_hashes: rustc_hash::FxHashMap::default(),
2946        };
2947
2948        let result = compute_file_scores_default(
2949            &modules,
2950            &file_paths,
2951            None,
2952            output,
2953            None,
2954            std::path::Path::new("/project"),
2955        )
2956        .unwrap();
2957        assert!(result.scores.is_empty());
2958    }
2959
2960    #[test]
2961    fn compute_file_scores_changed_since_filter() {
2962        let path_a = std::path::PathBuf::from("/src/a.ts");
2963        let path_b = std::path::PathBuf::from("/src/b.ts");
2964        let files = vec![
2965            crate::discover::DiscoveredFile {
2966                id: crate::discover::FileId(0),
2967                path: path_a.clone(),
2968                size_bytes: 100,
2969            },
2970            crate::discover::DiscoveredFile {
2971                id: crate::discover::FileId(1),
2972                path: path_b.clone(),
2973                size_bytes: 100,
2974            },
2975        ];
2976
2977        let resolved_modules = vec![
2978            fallow_graph::resolve::ResolvedModule {
2979                file_id: crate::discover::FileId(0),
2980                path: path_a,
2981                ..Default::default()
2982            },
2983            fallow_graph::resolve::ResolvedModule {
2984                file_id: crate::discover::FileId(1),
2985                path: path_b.clone(),
2986                ..Default::default()
2987            },
2988        ];
2989
2990        let graph = build_test_graph(&files, &[], &resolved_modules);
2991
2992        let modules = vec![
2993            make_module_info(
2994                0,
2995                10,
2996                vec![fallow_types::extract::FunctionComplexity {
2997                    name: "fn_a".into(),
2998                    line: 1,
2999                    col: 0,
3000                    cyclomatic: 2,
3001                    cognitive: 1,
3002                    line_count: 10,
3003                    param_count: 0,
3004                    react_hook_count: 0,
3005                    react_jsx_max_depth: 0,
3006                    react_prop_count: 0,
3007                    source_hash: None,
3008                    contributions: Vec::new(),
3009                }],
3010            ),
3011            make_module_info(
3012                1,
3013                10,
3014                vec![fallow_types::extract::FunctionComplexity {
3015                    name: "fn_b".into(),
3016                    line: 1,
3017                    col: 0,
3018                    cyclomatic: 3,
3019                    cognitive: 2,
3020                    line_count: 10,
3021                    param_count: 0,
3022                    react_hook_count: 0,
3023                    react_jsx_max_depth: 0,
3024                    react_prop_count: 0,
3025                    source_hash: None,
3026                    contributions: Vec::new(),
3027                }],
3028            ),
3029        ];
3030
3031        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3032            rustc_hash::FxHashMap::default();
3033        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3034        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3035
3036        let path_b_check = std::path::PathBuf::from("/src/b.ts");
3037        let mut changed = rustc_hash::FxHashSet::default();
3038        changed.insert(path_b);
3039
3040        let output = crate::results::DeadCodeAnalysisArtifacts {
3041            results: fallow_types::results::AnalysisResults::default(),
3042            timings: None,
3043            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3044            modules: None,
3045            files: None,
3046            script_used_packages: rustc_hash::FxHashSet::default(),
3047            file_hashes: rustc_hash::FxHashMap::default(),
3048        };
3049
3050        let result = compute_file_scores_default(
3051            &modules,
3052            &file_paths,
3053            Some(&changed),
3054            output,
3055            None,
3056            std::path::Path::new("/project"),
3057        )
3058        .unwrap();
3059        assert_eq!(result.scores.len(), 1);
3060        assert_eq!(result.scores[0].path, path_b_check);
3061    }
3062
3063    #[test]
3064    fn compute_file_scores_sorted_by_triage_concern() {
3065        let path_a = std::path::PathBuf::from("/src/a.ts");
3066        let path_b = std::path::PathBuf::from("/src/b.ts");
3067        let files = vec![
3068            crate::discover::DiscoveredFile {
3069                id: crate::discover::FileId(0),
3070                path: path_a.clone(),
3071                size_bytes: 100,
3072            },
3073            crate::discover::DiscoveredFile {
3074                id: crate::discover::FileId(1),
3075                path: path_b.clone(),
3076                size_bytes: 100,
3077            },
3078        ];
3079
3080        let resolved_modules = vec![
3081            fallow_graph::resolve::ResolvedModule {
3082                file_id: crate::discover::FileId(0),
3083                path: path_a.clone(),
3084                ..Default::default()
3085            },
3086            fallow_graph::resolve::ResolvedModule {
3087                file_id: crate::discover::FileId(1),
3088                path: path_b,
3089                ..Default::default()
3090            },
3091        ];
3092
3093        let graph = build_test_graph(&files, &[], &resolved_modules);
3094
3095        let modules = vec![
3096            make_module_info(
3097                0,
3098                10,
3099                vec![fallow_types::extract::FunctionComplexity {
3100                    name: "complex_fn".into(),
3101                    line: 1,
3102                    col: 0,
3103                    cyclomatic: 30,
3104                    cognitive: 20,
3105                    line_count: 10,
3106                    param_count: 0,
3107                    react_hook_count: 0,
3108                    react_jsx_max_depth: 0,
3109                    react_prop_count: 0,
3110                    source_hash: None,
3111                    contributions: Vec::new(),
3112                }],
3113            ),
3114            make_module_info(
3115                1,
3116                100,
3117                vec![fallow_types::extract::FunctionComplexity {
3118                    name: "simple_fn".into(),
3119                    line: 1,
3120                    col: 0,
3121                    cyclomatic: 1,
3122                    cognitive: 0,
3123                    line_count: 100,
3124                    param_count: 0,
3125                    react_hook_count: 0,
3126                    react_jsx_max_depth: 0,
3127                    react_prop_count: 0,
3128                    source_hash: None,
3129                    contributions: Vec::new(),
3130                }],
3131            ),
3132        ];
3133
3134        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3135            rustc_hash::FxHashMap::default();
3136        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3137        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3138
3139        let output = crate::results::DeadCodeAnalysisArtifacts {
3140            results: fallow_types::results::AnalysisResults::default(),
3141            timings: None,
3142            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3143            modules: None,
3144            files: None,
3145            script_used_packages: rustc_hash::FxHashSet::default(),
3146            file_hashes: rustc_hash::FxHashMap::default(),
3147        };
3148
3149        let result = compute_file_scores_default(
3150            &modules,
3151            &file_paths,
3152            None,
3153            output,
3154            None,
3155            std::path::Path::new("/project"),
3156        )
3157        .unwrap();
3158        assert_eq!(result.scores.len(), 2);
3159        assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
3160        assert_eq!(result.scores[0].path, path_a);
3161    }
3162
3163    #[test]
3164    fn compute_file_scores_with_unused_file_populates_evidence() {
3165        let path_a = std::path::PathBuf::from("/src/unused.ts");
3166        let files = vec![crate::discover::DiscoveredFile {
3167            id: crate::discover::FileId(0),
3168            path: path_a.clone(),
3169            size_bytes: 100,
3170        }];
3171
3172        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3173            file_id: crate::discover::FileId(0),
3174            path: path_a.clone(),
3175            exports: vec![fallow_types::extract::ExportInfo {
3176                name: crate::source::ExportName::Named("orphan".into()),
3177                local_name: None,
3178                is_type_only: false,
3179                visibility: crate::source::VisibilityTag::None,
3180                expected_unused_reason: None,
3181                span: oxc_span::Span::empty(0),
3182                members: vec![],
3183                is_side_effect_used: false,
3184                super_class: None,
3185            }]
3186            .into(),
3187            ..Default::default()
3188        }];
3189
3190        let graph = build_test_graph(&files, &[], &resolved_modules);
3191
3192        let modules = vec![make_module_info(
3193            0,
3194            10,
3195            vec![fallow_types::extract::FunctionComplexity {
3196                name: "orphan".into(),
3197                line: 1,
3198                col: 0,
3199                cyclomatic: 1,
3200                cognitive: 0,
3201                line_count: 10,
3202                param_count: 0,
3203                react_hook_count: 0,
3204                react_jsx_max_depth: 0,
3205                react_prop_count: 0,
3206                source_hash: None,
3207                contributions: Vec::new(),
3208            }],
3209        )];
3210
3211        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3212            rustc_hash::FxHashMap::default();
3213        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3214
3215        let mut results = fallow_types::results::AnalysisResults::default();
3216        results.unused_files.push(
3217            fallow_types::output_dead_code::UnusedFileFinding::with_actions(
3218                fallow_types::results::UnusedFile {
3219                    path: path_a.clone(),
3220                },
3221            ),
3222        );
3223
3224        let output = crate::results::DeadCodeAnalysisArtifacts {
3225            results,
3226            timings: None,
3227            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3228            modules: None,
3229            files: None,
3230            script_used_packages: rustc_hash::FxHashSet::default(),
3231            file_hashes: rustc_hash::FxHashMap::default(),
3232        };
3233
3234        let result = compute_file_scores_default(
3235            &modules,
3236            &file_paths,
3237            None,
3238            output,
3239            None,
3240            std::path::Path::new("/project"),
3241        )
3242        .unwrap();
3243        assert_eq!(result.scores.len(), 1);
3244        assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
3245        assert!(result.unused_export_names.contains_key(&path_a));
3246        let names = &result.unused_export_names[&path_a];
3247        assert_eq!(names, &["orphan"]);
3248        assert_eq!(result.analysis_counts.dead_files, 1);
3249    }
3250
3251    #[test]
3252    #[expect(
3253        clippy::too_many_lines,
3254        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3255    )]
3256    fn compute_file_scores_tracks_top_complex_functions() {
3257        let path_a = std::path::PathBuf::from("/src/complex.ts");
3258        let files = vec![crate::discover::DiscoveredFile {
3259            id: crate::discover::FileId(0),
3260            path: path_a.clone(),
3261            size_bytes: 500,
3262        }];
3263
3264        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3265            file_id: crate::discover::FileId(0),
3266            path: path_a.clone(),
3267            ..Default::default()
3268        }];
3269
3270        let graph = build_test_graph(&files, &[], &resolved_modules);
3271
3272        let modules = vec![make_module_info(
3273            0,
3274            50,
3275            vec![
3276                fallow_types::extract::FunctionComplexity {
3277                    name: "high".into(),
3278                    line: 1,
3279                    col: 0,
3280                    cyclomatic: 10,
3281                    cognitive: 20,
3282                    line_count: 10,
3283                    param_count: 0,
3284                    react_hook_count: 0,
3285                    react_jsx_max_depth: 0,
3286                    react_prop_count: 0,
3287                    source_hash: None,
3288                    contributions: Vec::new(),
3289                },
3290                fallow_types::extract::FunctionComplexity {
3291                    name: "medium".into(),
3292                    line: 11,
3293                    col: 0,
3294                    cyclomatic: 5,
3295                    cognitive: 10,
3296                    line_count: 10,
3297                    param_count: 0,
3298                    react_hook_count: 0,
3299                    react_jsx_max_depth: 0,
3300                    react_prop_count: 0,
3301                    source_hash: None,
3302                    contributions: Vec::new(),
3303                },
3304                fallow_types::extract::FunctionComplexity {
3305                    name: "low".into(),
3306                    line: 21,
3307                    col: 0,
3308                    cyclomatic: 2,
3309                    cognitive: 5,
3310                    line_count: 10,
3311                    param_count: 0,
3312                    react_hook_count: 0,
3313                    react_jsx_max_depth: 0,
3314                    react_prop_count: 0,
3315                    source_hash: None,
3316                    contributions: Vec::new(),
3317                },
3318                fallow_types::extract::FunctionComplexity {
3319                    name: "trivial".into(),
3320                    line: 31,
3321                    col: 0,
3322                    cyclomatic: 1,
3323                    cognitive: 1,
3324                    line_count: 10,
3325                    param_count: 0,
3326                    react_hook_count: 0,
3327                    react_jsx_max_depth: 0,
3328                    react_prop_count: 0,
3329                    source_hash: None,
3330                    contributions: Vec::new(),
3331                },
3332            ],
3333        )];
3334
3335        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3336            rustc_hash::FxHashMap::default();
3337        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3338
3339        let output = crate::results::DeadCodeAnalysisArtifacts {
3340            results: fallow_types::results::AnalysisResults::default(),
3341            timings: None,
3342            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3343            modules: None,
3344            files: None,
3345            script_used_packages: rustc_hash::FxHashSet::default(),
3346            file_hashes: rustc_hash::FxHashMap::default(),
3347        };
3348
3349        let result = compute_file_scores_default(
3350            &modules,
3351            &file_paths,
3352            None,
3353            output,
3354            None,
3355            std::path::Path::new("/project"),
3356        )
3357        .unwrap();
3358        assert!(result.top_complex_fns.contains_key(&path_a));
3359        let top = &result.top_complex_fns[&path_a];
3360        assert_eq!(top.len(), 3);
3361        assert_eq!(top[0].0, "high");
3362        assert_eq!(top[0].2, 20);
3363        assert_eq!(top[1].0, "medium");
3364        assert_eq!(top[1].2, 10);
3365        assert_eq!(top[2].0, "low");
3366        assert_eq!(top[2].2, 5);
3367    }
3368
3369    #[test]
3370    #[expect(
3371        clippy::too_many_lines,
3372        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3373    )]
3374    fn compute_file_scores_with_circular_deps() {
3375        let path_a = std::path::PathBuf::from("/src/a.ts");
3376        let path_b = std::path::PathBuf::from("/src/b.ts");
3377        let files = vec![
3378            crate::discover::DiscoveredFile {
3379                id: crate::discover::FileId(0),
3380                path: path_a.clone(),
3381                size_bytes: 100,
3382            },
3383            crate::discover::DiscoveredFile {
3384                id: crate::discover::FileId(1),
3385                path: path_b.clone(),
3386                size_bytes: 100,
3387            },
3388        ];
3389
3390        let resolved_modules = vec![
3391            fallow_graph::resolve::ResolvedModule {
3392                file_id: crate::discover::FileId(0),
3393                path: path_a.clone(),
3394                ..Default::default()
3395            },
3396            fallow_graph::resolve::ResolvedModule {
3397                file_id: crate::discover::FileId(1),
3398                path: path_b.clone(),
3399                ..Default::default()
3400            },
3401        ];
3402
3403        let graph = build_test_graph(&files, &[], &resolved_modules);
3404
3405        let modules = vec![
3406            make_module_info(
3407                0,
3408                10,
3409                vec![fallow_types::extract::FunctionComplexity {
3410                    name: "fn_a".into(),
3411                    line: 1,
3412                    col: 0,
3413                    cyclomatic: 2,
3414                    cognitive: 1,
3415                    line_count: 10,
3416                    param_count: 0,
3417                    react_hook_count: 0,
3418                    react_jsx_max_depth: 0,
3419                    react_prop_count: 0,
3420                    source_hash: None,
3421                    contributions: Vec::new(),
3422                }],
3423            ),
3424            make_module_info(
3425                1,
3426                10,
3427                vec![fallow_types::extract::FunctionComplexity {
3428                    name: "fn_b".into(),
3429                    line: 1,
3430                    col: 0,
3431                    cyclomatic: 3,
3432                    cognitive: 2,
3433                    line_count: 10,
3434                    param_count: 0,
3435                    react_hook_count: 0,
3436                    react_jsx_max_depth: 0,
3437                    react_prop_count: 0,
3438                    source_hash: None,
3439                    contributions: Vec::new(),
3440                }],
3441            ),
3442        ];
3443
3444        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3445            rustc_hash::FxHashMap::default();
3446        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3447        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3448
3449        let mut results = fallow_types::results::AnalysisResults::default();
3450        results.circular_dependencies.push(
3451            fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
3452                fallow_types::results::CircularDependency {
3453                    files: vec![path_a.clone(), path_b.clone()],
3454                    length: 2,
3455                    line: 1,
3456                    col: 0,
3457                    edges: Vec::new(),
3458                    is_cross_package: false,
3459                },
3460            ),
3461        );
3462
3463        let output = crate::results::DeadCodeAnalysisArtifacts {
3464            results,
3465            timings: None,
3466            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3467            modules: None,
3468            files: None,
3469            script_used_packages: rustc_hash::FxHashSet::default(),
3470            file_hashes: rustc_hash::FxHashMap::default(),
3471        };
3472
3473        let result = compute_file_scores_default(
3474            &modules,
3475            &file_paths,
3476            None,
3477            output,
3478            None,
3479            std::path::Path::new("/project"),
3480        )
3481        .unwrap();
3482        assert!(result.circular_files.contains(&path_a));
3483        assert!(result.circular_files.contains(&path_b));
3484        assert!(result.cycle_members.contains_key(&path_a));
3485        assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
3486        assert!(result.cycle_members.contains_key(&path_b));
3487        assert_eq!(result.cycle_members[&path_b], vec![path_a]);
3488        assert_eq!(result.analysis_counts.circular_deps, 1);
3489    }
3490
3491    #[test]
3492    #[expect(
3493        clippy::too_many_lines,
3494        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3495    )]
3496    fn compute_file_scores_analysis_counts_unused_exports_and_types() {
3497        let path_a = std::path::PathBuf::from("/src/a.ts");
3498        let files = vec![crate::discover::DiscoveredFile {
3499            id: crate::discover::FileId(0),
3500            path: path_a.clone(),
3501            size_bytes: 100,
3502        }];
3503
3504        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3505            file_id: crate::discover::FileId(0),
3506            path: path_a.clone(),
3507            exports: vec![
3508                fallow_types::extract::ExportInfo {
3509                    name: crate::source::ExportName::Named("foo".into()),
3510                    local_name: None,
3511                    is_type_only: false,
3512                    visibility: crate::source::VisibilityTag::None,
3513                    expected_unused_reason: None,
3514                    span: oxc_span::Span::empty(0),
3515                    members: vec![],
3516                    is_side_effect_used: false,
3517                    super_class: None,
3518                },
3519                fallow_types::extract::ExportInfo {
3520                    name: crate::source::ExportName::Named("bar".into()),
3521                    local_name: None,
3522                    is_type_only: false,
3523                    visibility: crate::source::VisibilityTag::None,
3524                    expected_unused_reason: None,
3525                    span: oxc_span::Span::empty(0),
3526                    members: vec![],
3527                    is_side_effect_used: false,
3528                    super_class: None,
3529                },
3530            ]
3531            .into(),
3532            ..Default::default()
3533        }];
3534
3535        let graph = build_test_graph(&files, &[], &resolved_modules);
3536
3537        let mut module = make_module_info(
3538            0,
3539            10,
3540            vec![fallow_types::extract::FunctionComplexity {
3541                name: "fn_a".into(),
3542                line: 1,
3543                col: 0,
3544                cyclomatic: 1,
3545                cognitive: 0,
3546                line_count: 10,
3547                param_count: 0,
3548                react_hook_count: 0,
3549                react_jsx_max_depth: 0,
3550                react_prop_count: 0,
3551                source_hash: None,
3552                contributions: Vec::new(),
3553            }],
3554        );
3555        module.exports = vec![
3556            fallow_types::extract::ExportInfo {
3557                name: crate::source::ExportName::Named("foo".into()),
3558                local_name: None,
3559                is_type_only: false,
3560                visibility: crate::source::VisibilityTag::None,
3561                expected_unused_reason: None,
3562                span: oxc_span::Span::empty(0),
3563                members: vec![],
3564                is_side_effect_used: false,
3565                super_class: None,
3566            },
3567            fallow_types::extract::ExportInfo {
3568                name: crate::source::ExportName::Named("bar".into()),
3569                local_name: None,
3570                is_type_only: false,
3571                visibility: crate::source::VisibilityTag::None,
3572                expected_unused_reason: None,
3573                span: oxc_span::Span::empty(0),
3574                members: vec![],
3575                is_side_effect_used: false,
3576                super_class: None,
3577            },
3578        ]
3579        .into();
3580        let modules = vec![module];
3581
3582        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3583            rustc_hash::FxHashMap::default();
3584        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3585
3586        let mut results = fallow_types::results::AnalysisResults::default();
3587        results.unused_exports.push(
3588            fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3589                fallow_types::results::UnusedExport {
3590                    path: path_a.clone(),
3591                    export_name: "foo".into(),
3592                    is_type_only: false,
3593                    line: 1,
3594                    col: 0,
3595                    span_start: 0,
3596                    is_re_export: false,
3597                },
3598            ),
3599        );
3600        results.unused_types.push(
3601            fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
3602                fallow_types::results::UnusedExport {
3603                    path: path_a,
3604                    export_name: "MyType".into(),
3605                    is_type_only: true,
3606                    line: 5,
3607                    col: 0,
3608                    span_start: 40,
3609                    is_re_export: false,
3610                },
3611            ),
3612        );
3613        results.unused_dependencies.push(
3614            fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
3615                fallow_types::results::UnusedDependency {
3616                    package_name: "lodash".into(),
3617                    location: fallow_types::results::DependencyLocation::Dependencies,
3618                    path: std::path::PathBuf::from("/package.json"),
3619                    line: 1,
3620                    used_in_workspaces: Vec::new(),
3621                },
3622            ),
3623        );
3624
3625        let output = crate::results::DeadCodeAnalysisArtifacts {
3626            results,
3627            timings: None,
3628            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3629            modules: None,
3630            files: None,
3631            script_used_packages: rustc_hash::FxHashSet::default(),
3632            file_hashes: rustc_hash::FxHashMap::default(),
3633        };
3634
3635        let result = compute_file_scores_default(
3636            &modules,
3637            &file_paths,
3638            None,
3639            output,
3640            None,
3641            std::path::Path::new("/project"),
3642        )
3643        .unwrap();
3644        assert_eq!(result.analysis_counts.total_exports, 2);
3645        assert_eq!(result.analysis_counts.dead_exports, 2);
3646        assert_eq!(result.analysis_counts.unused_deps, 1);
3647    }
3648
3649    /// Regression: total_exports must count graph modules, not extraction modules.
3650    #[test]
3651    #[expect(
3652        clippy::too_many_lines,
3653        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3654    )]
3655    fn total_exports_counts_graph_modules_not_extraction_modules() {
3656        let path_a = std::path::PathBuf::from("/src/a.ts");
3657        let files = vec![crate::discover::DiscoveredFile {
3658            id: crate::discover::FileId(0),
3659            path: path_a.clone(),
3660            size_bytes: 100,
3661        }];
3662
3663        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3664            file_id: crate::discover::FileId(0),
3665            path: path_a.clone(),
3666            exports: vec![
3667                fallow_types::extract::ExportInfo {
3668                    name: crate::source::ExportName::Named("foo".into()),
3669                    local_name: None,
3670                    is_type_only: false,
3671                    visibility: crate::source::VisibilityTag::None,
3672                    expected_unused_reason: None,
3673                    span: oxc_span::Span::empty(0),
3674                    members: vec![],
3675                    is_side_effect_used: false,
3676                    super_class: None,
3677                },
3678                fallow_types::extract::ExportInfo {
3679                    name: crate::source::ExportName::Named("bar".into()),
3680                    local_name: None,
3681                    is_type_only: false,
3682                    visibility: crate::source::VisibilityTag::None,
3683                    expected_unused_reason: None,
3684                    span: oxc_span::Span::empty(0),
3685                    members: vec![],
3686                    is_side_effect_used: false,
3687                    super_class: None,
3688                },
3689                fallow_types::extract::ExportInfo {
3690                    name: crate::source::ExportName::Named("baz".into()),
3691                    local_name: None,
3692                    is_type_only: false,
3693                    visibility: crate::source::VisibilityTag::None,
3694                    expected_unused_reason: None,
3695                    span: oxc_span::Span::new(0, 0),
3696                    members: vec![],
3697                    is_side_effect_used: false,
3698                    super_class: None,
3699                },
3700            ]
3701            .into(),
3702            ..Default::default()
3703        }];
3704
3705        let graph = build_test_graph(&files, &[], &resolved_modules);
3706
3707        let mut module = make_module_info(
3708            0,
3709            10,
3710            vec![fallow_types::extract::FunctionComplexity {
3711                name: "fn_a".into(),
3712                line: 1,
3713                col: 0,
3714                cyclomatic: 1,
3715                cognitive: 0,
3716                line_count: 10,
3717                param_count: 0,
3718                react_hook_count: 0,
3719                react_jsx_max_depth: 0,
3720                react_prop_count: 0,
3721                source_hash: None,
3722                contributions: Vec::new(),
3723            }],
3724        );
3725        module.exports = vec![
3726            fallow_types::extract::ExportInfo {
3727                name: crate::source::ExportName::Named("foo".into()),
3728                local_name: None,
3729                is_type_only: false,
3730                visibility: crate::source::VisibilityTag::None,
3731                expected_unused_reason: None,
3732                span: oxc_span::Span::empty(0),
3733                members: vec![],
3734                is_side_effect_used: false,
3735                super_class: None,
3736            },
3737            fallow_types::extract::ExportInfo {
3738                name: crate::source::ExportName::Named("bar".into()),
3739                local_name: None,
3740                is_type_only: false,
3741                visibility: crate::source::VisibilityTag::None,
3742                expected_unused_reason: None,
3743                span: oxc_span::Span::empty(0),
3744                members: vec![],
3745                is_side_effect_used: false,
3746                super_class: None,
3747            },
3748        ]
3749        .into();
3750        let modules = vec![module];
3751
3752        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3753            rustc_hash::FxHashMap::default();
3754        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3755
3756        let mut results = fallow_types::results::AnalysisResults::default();
3757        for name in ["foo", "bar", "baz"] {
3758            results.unused_exports.push(
3759                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3760                    fallow_types::results::UnusedExport {
3761                        path: path_a.clone(),
3762                        export_name: name.into(),
3763                        is_type_only: false,
3764                        line: 1,
3765                        col: 0,
3766                        span_start: 0,
3767                        is_re_export: name == "baz",
3768                    },
3769                ),
3770            );
3771        }
3772
3773        let output = crate::results::DeadCodeAnalysisArtifacts {
3774            results,
3775            timings: None,
3776            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3777            modules: None,
3778            files: None,
3779            script_used_packages: rustc_hash::FxHashSet::default(),
3780            file_hashes: rustc_hash::FxHashMap::default(),
3781        };
3782
3783        let result = compute_file_scores_default(
3784            &modules,
3785            &file_paths,
3786            None,
3787            output,
3788            None,
3789            std::path::Path::new("/project"),
3790        )
3791        .unwrap();
3792        assert_eq!(result.analysis_counts.total_exports, 3);
3793        assert_eq!(result.analysis_counts.dead_exports, 3);
3794    }
3795
3796    #[test]
3797    fn compute_file_scores_module_not_in_file_paths_skipped() {
3798        let path_a = std::path::PathBuf::from("/src/a.ts");
3799        let files = vec![crate::discover::DiscoveredFile {
3800            id: crate::discover::FileId(0),
3801            path: path_a.clone(),
3802            size_bytes: 100,
3803        }];
3804
3805        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3806            file_id: crate::discover::FileId(0),
3807            path: path_a,
3808            ..Default::default()
3809        }];
3810
3811        let graph = build_test_graph(&files, &[], &resolved_modules);
3812
3813        let modules = vec![make_module_info(
3814            0,
3815            10,
3816            vec![fallow_types::extract::FunctionComplexity {
3817                name: "fn_a".into(),
3818                line: 1,
3819                col: 0,
3820                cyclomatic: 2,
3821                cognitive: 1,
3822                line_count: 10,
3823                param_count: 0,
3824                react_hook_count: 0,
3825                react_jsx_max_depth: 0,
3826                react_prop_count: 0,
3827                source_hash: None,
3828                contributions: Vec::new(),
3829            }],
3830        )];
3831
3832        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3833            rustc_hash::FxHashMap::default();
3834
3835        let output = crate::results::DeadCodeAnalysisArtifacts {
3836            results: fallow_types::results::AnalysisResults::default(),
3837            timings: None,
3838            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3839            modules: None,
3840            files: None,
3841            script_used_packages: rustc_hash::FxHashSet::default(),
3842            file_hashes: rustc_hash::FxHashMap::default(),
3843        };
3844
3845        let result = compute_file_scores_default(
3846            &modules,
3847            &file_paths,
3848            None,
3849            output,
3850            None,
3851            std::path::Path::new("/project"),
3852        )
3853        .unwrap();
3854        assert!(result.scores.is_empty());
3855    }
3856
3857    #[test]
3858    fn compute_file_scores_mi_rounded_to_one_decimal() {
3859        let path_a = std::path::PathBuf::from("/src/a.ts");
3860        let files = vec![crate::discover::DiscoveredFile {
3861            id: crate::discover::FileId(0),
3862            path: path_a.clone(),
3863            size_bytes: 100,
3864        }];
3865
3866        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3867            file_id: crate::discover::FileId(0),
3868            path: path_a.clone(),
3869            ..Default::default()
3870        }];
3871
3872        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
3873
3874        let modules = vec![make_module_info(
3875            0,
3876            100,
3877            vec![fallow_types::extract::FunctionComplexity {
3878                name: "fn".into(),
3879                line: 1,
3880                col: 0,
3881                cyclomatic: 7,
3882                cognitive: 3,
3883                line_count: 100,
3884                param_count: 0,
3885                react_hook_count: 0,
3886                react_jsx_max_depth: 0,
3887                react_prop_count: 0,
3888                source_hash: None,
3889                contributions: Vec::new(),
3890            }],
3891        )];
3892
3893        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3894            rustc_hash::FxHashMap::default();
3895        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3896
3897        let output = crate::results::DeadCodeAnalysisArtifacts {
3898            results: fallow_types::results::AnalysisResults::default(),
3899            timings: None,
3900            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3901            modules: None,
3902            files: None,
3903            script_used_packages: rustc_hash::FxHashSet::default(),
3904            file_hashes: rustc_hash::FxHashMap::default(),
3905        };
3906
3907        let result = compute_file_scores_default(
3908            &modules,
3909            &file_paths,
3910            None,
3911            output,
3912            None,
3913            std::path::Path::new("/project"),
3914        )
3915        .unwrap();
3916        let mi = result.scores[0].maintainability_index;
3917        let rounded = (mi * 10.0).round() / 10.0;
3918        assert!((mi - rounded).abs() < f64::EPSILON);
3919    }
3920
3921    #[test]
3922    fn compute_file_scores_value_export_counts_tracked() {
3923        let path_a = std::path::PathBuf::from("/src/a.ts");
3924        let files = vec![crate::discover::DiscoveredFile {
3925            id: crate::discover::FileId(0),
3926            path: path_a.clone(),
3927            size_bytes: 100,
3928        }];
3929
3930        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3931            file_id: crate::discover::FileId(0),
3932            path: path_a.clone(),
3933            exports: vec![
3934                fallow_types::extract::ExportInfo {
3935                    name: crate::source::ExportName::Named("a".into()),
3936                    local_name: None,
3937                    is_type_only: false,
3938                    visibility: crate::source::VisibilityTag::None,
3939                    expected_unused_reason: None,
3940                    span: oxc_span::Span::empty(0),
3941                    members: vec![],
3942                    is_side_effect_used: false,
3943                    super_class: None,
3944                },
3945                fallow_types::extract::ExportInfo {
3946                    name: crate::source::ExportName::Named("b".into()),
3947                    local_name: None,
3948                    is_type_only: false,
3949                    visibility: crate::source::VisibilityTag::None,
3950                    expected_unused_reason: None,
3951                    span: oxc_span::Span::empty(0),
3952                    members: vec![],
3953                    is_side_effect_used: false,
3954                    super_class: None,
3955                },
3956                fallow_types::extract::ExportInfo {
3957                    name: crate::source::ExportName::Named("T".into()),
3958                    local_name: None,
3959                    is_type_only: true,
3960                    visibility: crate::source::VisibilityTag::None,
3961                    expected_unused_reason: None,
3962                    span: oxc_span::Span::empty(0),
3963                    members: vec![],
3964                    is_side_effect_used: false,
3965                    super_class: None,
3966                },
3967            ]
3968            .into(),
3969            ..Default::default()
3970        }];
3971
3972        let graph = build_test_graph(&files, &[], &resolved_modules);
3973
3974        let modules = vec![make_module_info(
3975            0,
3976            10,
3977            vec![fallow_types::extract::FunctionComplexity {
3978                name: "fn_a".into(),
3979                line: 1,
3980                col: 0,
3981                cyclomatic: 2,
3982                cognitive: 1,
3983                line_count: 10,
3984                param_count: 0,
3985                react_hook_count: 0,
3986                react_jsx_max_depth: 0,
3987                react_prop_count: 0,
3988                source_hash: None,
3989                contributions: Vec::new(),
3990            }],
3991        )];
3992
3993        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3994            rustc_hash::FxHashMap::default();
3995        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3996
3997        let output = crate::results::DeadCodeAnalysisArtifacts {
3998            results: fallow_types::results::AnalysisResults::default(),
3999            timings: None,
4000            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4001            modules: None,
4002            files: None,
4003            script_used_packages: rustc_hash::FxHashSet::default(),
4004            file_hashes: rustc_hash::FxHashMap::default(),
4005        };
4006
4007        let result = compute_file_scores_default(
4008            &modules,
4009            &file_paths,
4010            None,
4011            output,
4012            None,
4013            std::path::Path::new("/project"),
4014        )
4015        .unwrap();
4016        assert_eq!(result.value_export_counts[&path_a], 2);
4017    }
4018
4019    #[test]
4020    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
4021        let path_a = std::path::PathBuf::from("/src/simple.ts");
4022        let files = vec![crate::discover::DiscoveredFile {
4023            id: crate::discover::FileId(0),
4024            path: path_a.clone(),
4025            size_bytes: 100,
4026        }];
4027
4028        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4029            file_id: crate::discover::FileId(0),
4030            path: path_a.clone(),
4031            ..Default::default()
4032        }];
4033
4034        let graph = build_test_graph(&files, &[], &resolved_modules);
4035
4036        let modules = vec![make_module_info(
4037            0,
4038            10,
4039            vec![fallow_types::extract::FunctionComplexity {
4040                name: "trivial".into(),
4041                line: 1,
4042                col: 0,
4043                cyclomatic: 1,
4044                cognitive: 0,
4045                line_count: 10,
4046                param_count: 0,
4047                react_hook_count: 0,
4048                react_jsx_max_depth: 0,
4049                react_prop_count: 0,
4050                source_hash: None,
4051                contributions: Vec::new(),
4052            }],
4053        )];
4054
4055        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4056            rustc_hash::FxHashMap::default();
4057        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4058
4059        let output = crate::results::DeadCodeAnalysisArtifacts {
4060            results: fallow_types::results::AnalysisResults::default(),
4061            timings: None,
4062            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4063            modules: None,
4064            files: None,
4065            script_used_packages: rustc_hash::FxHashSet::default(),
4066            file_hashes: rustc_hash::FxHashMap::default(),
4067        };
4068
4069        let result = compute_file_scores_default(
4070            &modules,
4071            &file_paths,
4072            None,
4073            output,
4074            None,
4075            std::path::Path::new("/project"),
4076        )
4077        .unwrap();
4078        assert!(!result.top_complex_fns.contains_key(&path_a));
4079    }
4080
4081    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
4082        fallow_types::extract::FunctionComplexity {
4083            name: "test_fn".into(),
4084            line: 1,
4085            col: 0,
4086            cyclomatic,
4087            cognitive: 0,
4088            line_count: 10,
4089            param_count: 0,
4090            react_hook_count: 0,
4091            react_jsx_max_depth: 0,
4092            react_prop_count: 0,
4093            source_hash: None,
4094            contributions: Vec::new(),
4095        }
4096    }
4097
4098    fn make_named_fn_complexity(
4099        name: &str,
4100        line: u32,
4101        cyclomatic: u16,
4102    ) -> fallow_types::extract::FunctionComplexity {
4103        fallow_types::extract::FunctionComplexity {
4104            name: name.into(),
4105            line,
4106            col: 0,
4107            cyclomatic,
4108            cognitive: 0,
4109            line_count: 10,
4110            param_count: 0,
4111            react_hook_count: 0,
4112            react_jsx_max_depth: 0,
4113            react_prop_count: 0,
4114            source_hash: None,
4115            contributions: Vec::new(),
4116        }
4117    }
4118
4119    fn crap_override_entry(
4120        files: &[&str],
4121        functions: &[&str],
4122        max_crap: Option<f64>,
4123    ) -> fallow_config::HealthThresholdOverride {
4124        fallow_config::HealthThresholdOverride {
4125            files: files.iter().map(ToString::to_string).collect(),
4126            functions: functions.iter().map(ToString::to_string).collect(),
4127            max_cyclomatic: None,
4128            max_cognitive: None,
4129            max_crap,
4130            max_unit_size: None,
4131            reason: Some("test override".into()),
4132        }
4133    }
4134
4135    fn estimated_signals_with(
4136        resolver: &ThresholdOverrideResolver,
4137        relative: &str,
4138        enforce_crap: bool,
4139        complexity: &[fallow_types::extract::FunctionComplexity],
4140    ) -> CrapThresholdSignals {
4141        let ceilings = CrapCeilingLookup::new(
4142            CrapScoreThresholds {
4143                resolver,
4144                enforce_crap,
4145            },
4146            std::path::Path::new(relative),
4147        );
4148        compute_crap_scores_estimated(
4149            complexity,
4150            &rustc_hash::FxHashSet::default(),
4151            false,
4152            fallow_output::CoverageSource::Estimated,
4153            &ceilings,
4154        )
4155        .signals
4156    }
4157
4158    #[test]
4159    fn crap_counting_exempts_functions_under_override_ceiling() {
4160        // The issue's repro: two untested cyclomatic-10 functions (CRAP 110)
4161        // under an override raising maxCrap to 500 on the file.
4162        let resolver =
4163            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
4164        let fns = vec![
4165            make_named_fn_complexity("a", 1, 10),
4166            make_named_fn_complexity("b", 12, 10),
4167        ];
4168
4169        let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4170        assert_eq!(covered.above, 0);
4171        assert_eq!(covered.exempted, 2);
4172        assert_eq!(covered.min_ceiling, Some(500.0));
4173
4174        let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
4175        assert_eq!(elsewhere.above, 2);
4176        assert_eq!(elsewhere.exempted, 0);
4177        assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
4178    }
4179
4180    #[test]
4181    fn crap_counting_insufficient_override_keeps_count() {
4182        let resolver =
4183            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
4184        let fns = vec![
4185            make_named_fn_complexity("a", 1, 10),
4186            make_named_fn_complexity("b", 12, 10),
4187        ];
4188
4189        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4190        assert_eq!(signals.above, 2);
4191        assert_eq!(signals.exempted, 0);
4192        assert_eq!(signals.min_ceiling, Some(50.0));
4193    }
4194
4195    #[test]
4196    fn crap_counting_partial_function_override() {
4197        // Only `a` is exempted; `b` keeps the global ceiling, which is also
4198        // the file's lowest effective ceiling.
4199        let resolver =
4200            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
4201        let fns = vec![
4202            make_named_fn_complexity("a", 1, 10),
4203            make_named_fn_complexity("b", 12, 10),
4204        ];
4205
4206        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4207        assert_eq!(signals.above, 1);
4208        assert_eq!(signals.exempted, 1);
4209        assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
4210    }
4211
4212    #[test]
4213    fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
4214        // Global maxCrap 0 disables enforcement: nothing is above threshold
4215        // and every canonical-baseline breach is disclosed as exempt.
4216        let resolver = test_crap_resolver(0.0);
4217        let fns = vec![
4218            make_named_fn_complexity("a", 1, 10),
4219            make_named_fn_complexity("b", 12, 10),
4220            make_named_fn_complexity("tiny", 24, 1),
4221        ];
4222
4223        let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
4224        assert_eq!(signals.above, 0);
4225        assert_eq!(signals.exempted, 2);
4226    }
4227
4228    #[test]
4229    fn crap_counting_stricter_ceiling_never_counts_exempt() {
4230        // A ceiling below the canonical baseline flags the band between it and
4231        // 30 as above-threshold, never as exempt.
4232        let resolver = test_crap_resolver(10.0);
4233        let fns = vec![make_named_fn_complexity("a", 1, 4)]; // untested CRAP 20
4234
4235        let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
4236        assert_eq!(signals.above, 1);
4237        assert_eq!(signals.exempted, 0);
4238    }
4239
4240    #[test]
4241    fn crap_counting_uses_rounded_value_at_boundary() {
4242        // Istanbul coverage tuned so unrounded CRAP is 29.96, which rounds to
4243        // the stored per-function 30.0. The findings pipeline compares the
4244        // ROUNDED value against the ceiling and emits a finding; the count
4245        // must agree at the same boundary (issue #2228).
4246        let funcs = vec![make_fn_complexity(10)];
4247        let mut functions = rustc_hash::FxHashMap::default();
4248        functions.insert(("test_fn".to_string(), 1, 0), 41.56);
4249        let file_cov = IstanbulFileCoverage { functions };
4250
4251        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4252        assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
4253        assert_eq!(result.signals.above, 1);
4254        assert_eq!(result.signals.exempted, 0);
4255    }
4256
4257    #[test]
4258    fn compute_file_scores_discloses_override_exemption_on_row() {
4259        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
4260        let files = vec![crate::discover::DiscoveredFile {
4261            id: crate::discover::FileId(0),
4262            path: path_a.clone(),
4263            size_bytes: 100,
4264        }];
4265        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4266            file_id: crate::discover::FileId(0),
4267            path: path_a.clone(),
4268            ..Default::default()
4269        }];
4270        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4271        let modules = vec![make_module_info(
4272            0,
4273            26,
4274            vec![
4275                make_named_fn_complexity("a", 1, 10),
4276                make_named_fn_complexity("b", 12, 10),
4277            ],
4278        )];
4279        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4280            rustc_hash::FxHashMap::default();
4281        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4282        let output = crate::results::DeadCodeAnalysisArtifacts {
4283            results: fallow_types::results::AnalysisResults::default(),
4284            timings: None,
4285            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4286            modules: None,
4287            files: None,
4288            script_used_packages: rustc_hash::FxHashSet::default(),
4289            file_hashes: rustc_hash::FxHashMap::default(),
4290        };
4291
4292        let resolver =
4293            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
4294        let result = compute_file_scores(
4295            FileScoreComputeInput {
4296                modules: &modules,
4297                file_paths: &file_paths,
4298                changed_files: None,
4299                istanbul_coverage: None,
4300                root: std::path::Path::new("/project"),
4301                crap_thresholds: CrapScoreThresholds {
4302                    resolver: &resolver,
4303                    enforce_crap: true,
4304                },
4305            },
4306            output,
4307        )
4308        .unwrap();
4309
4310        assert_eq!(result.scores.len(), 1);
4311        let score = &result.scores[0];
4312        assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
4313        assert_eq!(score.crap_above_threshold, 0);
4314        assert_eq!(score.crap_exempted, 2);
4315        assert_eq!(score.crap_effective_threshold, Some(500.0));
4316        assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
4317        assert_eq!(
4318            file_score_concern_axis(score, CRAP_THRESHOLD),
4319            FileScoreConcern::Structural
4320        );
4321    }
4322
4323    #[test]
4324    fn compute_file_scores_raised_global_omits_row_threshold() {
4325        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
4326        let files = vec![crate::discover::DiscoveredFile {
4327            id: crate::discover::FileId(0),
4328            path: path_a.clone(),
4329            size_bytes: 100,
4330        }];
4331        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4332            file_id: crate::discover::FileId(0),
4333            path: path_a.clone(),
4334            ..Default::default()
4335        }];
4336        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4337        let modules = vec![make_module_info(
4338            0,
4339            26,
4340            vec![
4341                make_named_fn_complexity("a", 1, 10),
4342                make_named_fn_complexity("b", 12, 10),
4343            ],
4344        )];
4345        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4346            rustc_hash::FxHashMap::default();
4347        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4348        let output = crate::results::DeadCodeAnalysisArtifacts {
4349            results: fallow_types::results::AnalysisResults::default(),
4350            timings: None,
4351            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4352            modules: None,
4353            files: None,
4354            script_used_packages: rustc_hash::FxHashSet::default(),
4355            file_hashes: rustc_hash::FxHashMap::default(),
4356        };
4357
4358        // Raised via the global (`--max-crap 5000`), not an override: the row
4359        // must not repeat the run global as its own effective threshold.
4360        let resolver = test_crap_resolver(5000.0);
4361        let result = compute_file_scores(
4362            FileScoreComputeInput {
4363                modules: &modules,
4364                file_paths: &file_paths,
4365                changed_files: None,
4366                istanbul_coverage: None,
4367                root: std::path::Path::new("/project"),
4368                crap_thresholds: CrapScoreThresholds {
4369                    resolver: &resolver,
4370                    enforce_crap: true,
4371                },
4372            },
4373            output,
4374        )
4375        .unwrap();
4376
4377        assert_eq!(result.scores.len(), 1);
4378        let score = &result.scores[0];
4379        assert_eq!(score.crap_above_threshold, 0);
4380        assert_eq!(score.crap_exempted, 2);
4381        assert_eq!(score.crap_effective_threshold, None);
4382        assert!(file_score_fully_crap_exempt(score, 5000.0));
4383        assert_eq!(
4384            file_score_concern_axis(score, 5000.0),
4385            FileScoreConcern::Structural
4386        );
4387    }
4388
4389    #[test]
4390    fn crap_scores_empty_complexity() {
4391        let (max, above) = compute_crap_scores_binary(&[], true);
4392        assert!((max).abs() < f64::EPSILON);
4393        assert_eq!(above, 0);
4394    }
4395
4396    #[test]
4397    fn crap_scores_test_reachable() {
4398        let funcs = vec![make_fn_complexity(5)];
4399        let (max, above) = compute_crap_scores_binary(&funcs, true);
4400        assert!((max - 5.0).abs() < f64::EPSILON);
4401        assert_eq!(above, 0);
4402    }
4403
4404    #[test]
4405    fn crap_scores_untested_at_threshold() {
4406        let funcs = vec![make_fn_complexity(5)];
4407        let (max, above) = compute_crap_scores_binary(&funcs, false);
4408        assert!((max - 30.0).abs() < f64::EPSILON);
4409        assert_eq!(above, 1);
4410    }
4411
4412    #[test]
4413    fn crap_scores_untested_above_threshold() {
4414        let funcs = vec![make_fn_complexity(6)];
4415        let (max, above) = compute_crap_scores_binary(&funcs, false);
4416        assert!((max - 42.0).abs() < f64::EPSILON);
4417        assert_eq!(above, 1);
4418    }
4419
4420    #[test]
4421    fn crap_scores_untested_below_threshold() {
4422        let funcs = vec![make_fn_complexity(4)];
4423        let (max, above) = compute_crap_scores_binary(&funcs, false);
4424        assert!((max - 20.0).abs() < f64::EPSILON);
4425        assert_eq!(above, 0);
4426    }
4427
4428    #[test]
4429    fn crap_scores_mixed_functions_untested() {
4430        let funcs = vec![
4431            make_fn_complexity(2),
4432            make_fn_complexity(5),
4433            make_fn_complexity(8),
4434        ];
4435        let (max, above) = compute_crap_scores_binary(&funcs, false);
4436        assert!((max - 72.0).abs() < f64::EPSILON);
4437        assert_eq!(above, 2);
4438    }
4439
4440    #[test]
4441    fn crap_formula_full_coverage() {
4442        let result = crap_formula(10.0, 100.0);
4443        assert!((result - 10.0).abs() < f64::EPSILON);
4444    }
4445
4446    #[test]
4447    fn crap_formula_zero_coverage() {
4448        let result = crap_formula(5.0, 0.0);
4449        assert!((result - 30.0).abs() < f64::EPSILON);
4450    }
4451
4452    #[test]
4453    fn crap_formula_partial_coverage() {
4454        let result = crap_formula(10.0, 50.0);
4455        assert!((result - 22.5).abs() < f64::EPSILON);
4456    }
4457
4458    #[test]
4459    fn crap_formula_high_coverage_low_complexity() {
4460        let result = crap_formula(2.0, 90.0);
4461        assert!((result - 2.004).abs() < 0.001);
4462    }
4463
4464    /// Pin the exact cyclomatic value at which the default CRAP gate (30.0)
4465    /// trips for each estimated-coverage tier. These numbers back the
4466    /// changelog and docs wording: 5 at the 0% tier, 10 at the 40% indirect
4467    /// tier, 28 at the 85% direct tier.
4468    #[test]
4469    fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
4470        for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
4471            assert!(
4472                crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
4473                "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
4474            );
4475            assert!(
4476                crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
4477                "cyclomatic {} at {coverage_pct}% must stay under the gate",
4478                gate_cc - 1.0
4479            );
4480        }
4481    }
4482
4483    #[test]
4484    fn istanbul_crap_excludes_synthetic_template_units() {
4485        let funcs = vec![
4486            make_named_fn_complexity("<template>", 1, 21),
4487            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
4488            make_fn_complexity(6),
4489        ];
4490        let result = istanbul_crap_default(&funcs, None, false);
4491        assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
4492        assert_eq!(result.signals.above, 1);
4493        assert_eq!(
4494            result.total, 1,
4495            "template units must not count as unmatched"
4496        );
4497        assert_eq!(result.per_function.len(), 1);
4498    }
4499
4500    #[test]
4501    fn estimated_crap_excludes_synthetic_template_units() {
4502        let funcs = vec![
4503            make_named_fn_complexity("<template>", 1, 21),
4504            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
4505        ];
4506        let result = estimated_crap_default(
4507            &funcs,
4508            &rustc_hash::FxHashSet::default(),
4509            false,
4510            fallow_output::CoverageSource::Estimated,
4511        );
4512        assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
4513        assert_eq!(result.signals.above, 0);
4514        assert!(result.per_function.is_empty());
4515    }
4516
4517    #[test]
4518    fn istanbul_crap_with_coverage_data() {
4519        let funcs = vec![make_fn_complexity(10)];
4520        let mut functions = rustc_hash::FxHashMap::default();
4521        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4522        let file_cov = IstanbulFileCoverage { functions };
4523        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4524        assert!((result.max_crap - 10.8).abs() < 0.1);
4525        assert_eq!(result.signals.above, 0);
4526    }
4527
4528    #[test]
4529    fn istanbul_crap_falls_back_to_binary_when_no_match() {
4530        let funcs = vec![make_fn_complexity(6)];
4531        let file_cov = IstanbulFileCoverage {
4532            functions: rustc_hash::FxHashMap::default(),
4533        };
4534        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4535        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
4536        assert_eq!(result.signals.above, 1);
4537    }
4538
4539    #[test]
4540    fn istanbul_crap_falls_back_to_binary_when_no_file_coverage() {
4541        let funcs = vec![make_fn_complexity(5)];
4542        let result = istanbul_crap_default(&funcs, None, true);
4543        assert!((result.max_crap - 5.0).abs() < f64::EPSILON);
4544        assert_eq!(result.signals.above, 0);
4545    }
4546
4547    #[test]
4548    fn istanbul_crap_zero_coverage_matches_binary_untested() {
4549        let funcs = vec![make_fn_complexity(5)];
4550        let mut functions = rustc_hash::FxHashMap::default();
4551        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
4552        let file_cov = IstanbulFileCoverage { functions };
4553        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4554        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
4555        assert_eq!(result.signals.above, 1);
4556    }
4557
4558    #[test]
4559    fn estimated_crap_direct_test_reference() {
4560        let funcs = vec![make_fn_complexity(10)];
4561        let mut refs = rustc_hash::FxHashSet::default();
4562        refs.insert("test_fn".to_string());
4563        let result = estimated_crap_default(
4564            &funcs,
4565            &refs,
4566            true,
4567            fallow_output::CoverageSource::Estimated,
4568        );
4569        let (max, above) = (result.max_crap, result.signals.above);
4570        assert!((max - 10.3).abs() < 0.1);
4571        assert_eq!(above, 0);
4572    }
4573
4574    #[test]
4575    fn estimated_crap_indirect_test_reachable() {
4576        let funcs = vec![make_fn_complexity(10)];
4577        let refs = rustc_hash::FxHashSet::default();
4578        let result = estimated_crap_default(
4579            &funcs,
4580            &refs,
4581            true,
4582            fallow_output::CoverageSource::Estimated,
4583        );
4584        let (max, above) = (result.max_crap, result.signals.above);
4585        assert!((max - 31.6).abs() < 0.1);
4586        assert_eq!(above, 1);
4587    }
4588
4589    #[test]
4590    fn estimated_crap_untested_file() {
4591        let funcs = vec![make_fn_complexity(5)];
4592        let refs = rustc_hash::FxHashSet::default();
4593        let result = estimated_crap_default(
4594            &funcs,
4595            &refs,
4596            false,
4597            fallow_output::CoverageSource::Estimated,
4598        );
4599        let (max, above) = (result.max_crap, result.signals.above);
4600        assert!((max - 30.0).abs() < f64::EPSILON);
4601        assert_eq!(above, 1);
4602    }
4603
4604    #[test]
4605    fn estimated_crap_low_complexity_direct_ref() {
4606        let funcs = vec![make_fn_complexity(2)];
4607        let mut refs = rustc_hash::FxHashSet::default();
4608        refs.insert("test_fn".to_string());
4609        let result = estimated_crap_default(
4610            &funcs,
4611            &refs,
4612            true,
4613            fallow_output::CoverageSource::Estimated,
4614        );
4615        let (max, above) = (result.max_crap, result.signals.above);
4616        assert!(max < 3.0);
4617        assert_eq!(above, 0);
4618    }
4619
4620    #[test]
4621    fn estimated_crap_empty() {
4622        let refs = rustc_hash::FxHashSet::default();
4623        let result =
4624            estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
4625        let (max, above) = (result.max_crap, result.signals.above);
4626        assert!((max).abs() < f64::EPSILON);
4627        assert_eq!(above, 0);
4628    }
4629
4630    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
4631        fallow_graph::graph::ExportSymbol {
4632            name: fallow_types::extract::ExportName::Named(name.into()),
4633            is_type_only,
4634            is_side_effect_used: false,
4635            visibility: crate::source::VisibilityTag::None,
4636            expected_unused_reason: None,
4637            span: oxc_span::Span::default(),
4638            references: vec![],
4639            reference_paths: Vec::new(),
4640            members: vec![],
4641        }
4642    }
4643
4644    #[test]
4645    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
4646        let path = std::path::Path::new("src/types.ts");
4647        let exports = vec![
4648            make_export("MyInterface", true),
4649            make_export("MyType", true),
4650            make_export("myFunction", false),
4651        ];
4652        let unused_files = rustc_hash::FxHashSet::default();
4653        let mut unused_by_path = rustc_hash::FxHashMap::default();
4654        unused_by_path.insert(path, 1_usize); // 1 unused value export
4655
4656        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4657        assert!((ratio - 1.0).abs() < f64::EPSILON);
4658    }
4659
4660    #[test]
4661    fn dead_code_ratio_only_type_exports_returns_zero() {
4662        let path = std::path::Path::new("src/types.ts");
4663        let exports = vec![
4664            make_export("MyInterface", true),
4665            make_export("MyType", true),
4666        ];
4667        let unused_files = rustc_hash::FxHashSet::default();
4668        let unused_by_path = rustc_hash::FxHashMap::default();
4669
4670        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4671        assert!(ratio.abs() < f64::EPSILON);
4672    }
4673
4674    #[test]
4675    fn dead_code_ratio_mixed_exports_counts_only_values() {
4676        let path = std::path::Path::new("src/component.ts");
4677        let exports = vec![
4678            make_export("Props", true),
4679            make_export("State", true),
4680            make_export("Component", false),
4681            make_export("helper", false),
4682        ];
4683        let unused_files = rustc_hash::FxHashSet::default();
4684        let mut unused_by_path = rustc_hash::FxHashMap::default();
4685        unused_by_path.insert(path, 1_usize);
4686
4687        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4688        assert!((ratio - 0.5).abs() < f64::EPSILON);
4689    }
4690
4691    fn write_single_file_istanbul_fixture(
4692        coverage_path: &std::path::Path,
4693        source_path: &std::path::Path,
4694        fn_map: &serde_json::Value,
4695        function_hits: &serde_json::Value,
4696    ) {
4697        let mut root = serde_json::Map::new();
4698        root.insert(
4699            source_path.to_string_lossy().into_owned(),
4700            serde_json::json!({
4701                "path": source_path.to_string_lossy().into_owned(),
4702                "statementMap": {},
4703                "fnMap": fn_map,
4704                "branchMap": {},
4705                "s": {},
4706                "f": function_hits,
4707                "b": {}
4708            }),
4709        );
4710
4711        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
4712    }
4713
4714    #[test]
4715    fn resolve_relative_to_root_joins_relative_with_project_root() {
4716        let resolved = resolve_relative_to_root(
4717            std::path::Path::new("coverage/coverage-final.json"),
4718            Some(std::path::Path::new("/work/my-app")),
4719        );
4720        assert_eq!(
4721            resolved,
4722            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
4723        );
4724    }
4725
4726    #[test]
4727    fn resolve_relative_to_root_returns_absolute_unchanged() {
4728        let resolved = resolve_relative_to_root(
4729            std::path::Path::new("/tmp/coverage-final.json"),
4730            Some(std::path::Path::new("/work/my-app")),
4731        );
4732        assert_eq!(
4733            resolved,
4734            std::path::PathBuf::from("/tmp/coverage-final.json")
4735        );
4736    }
4737
4738    #[test]
4739    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
4740        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
4741        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
4742        assert_eq!(resolved, path);
4743    }
4744
4745    #[cfg(windows)]
4746    #[test]
4747    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
4748        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
4749        let resolved =
4750            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
4751        assert_eq!(resolved, path);
4752    }
4753
4754    #[test]
4755    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
4756        let resolved =
4757            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
4758        assert_eq!(
4759            resolved,
4760            std::path::PathBuf::from("coverage/coverage-final.json")
4761        );
4762    }
4763
4764    #[test]
4765    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
4766        let temp = tempfile::TempDir::new().unwrap();
4767        let source_path = temp.path().join("src/index.ts");
4768        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4769        std::fs::write(&source_path, "export function f(){}").unwrap();
4770
4771        let coverage_path = temp.path().join("coverage/coverage-final.json");
4772        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
4773        write_single_file_istanbul_fixture(
4774            &coverage_path,
4775            &source_path,
4776            &serde_json::json!({
4777                "0": {
4778                    "name": "f",
4779                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
4780                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
4781                }
4782            }),
4783            &serde_json::json!({ "0": 1 }),
4784        );
4785
4786        let coverage = load_istanbul_coverage(
4787            std::path::Path::new("coverage/coverage-final.json"),
4788            None,
4789            Some(temp.path()),
4790        )
4791        .expect("relative path must resolve against project_root");
4792        assert!(
4793            !coverage.files.is_empty(),
4794            "expected coverage to load via project_root resolution, got {} files",
4795            coverage.files.len()
4796        );
4797    }
4798
4799    #[test]
4800    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
4801        let temp = tempfile::TempDir::new().unwrap();
4802        let source_path = temp.path().join("src/service.ts");
4803        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4804        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
4805
4806        let coverage_path = temp.path().join("coverage-final.json");
4807        write_single_file_istanbul_fixture(
4808            &coverage_path,
4809            &source_path,
4810            &serde_json::json!({
4811                "0": {
4812                    "name": "(anonymous_0)",
4813                    "decl": {
4814                        "start": { "line": 5, "column": 2 },
4815                        "end": { "line": 5, "column": 13 }
4816                    },
4817                    "loc": {
4818                        "start": { "line": 5, "column": 14 },
4819                        "end": { "line": 11, "column": 3 }
4820                    }
4821                },
4822                "1": {
4823                    "name": "(anonymous_1)",
4824                    "decl": {
4825                        "start": { "line": 20, "column": 14 },
4826                        "end": { "line": 20, "column": 25 }
4827                    },
4828                    "loc": {
4829                        "start": { "line": 20, "column": 28 },
4830                        "end": { "line": 22, "column": 2 }
4831                    }
4832                }
4833            }),
4834            &serde_json::json!({
4835                "0": 1,
4836                "1": 0
4837            }),
4838        );
4839
4840        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4841        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4842        let file_coverage = coverage.get(&canonical_source).unwrap();
4843
4844        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
4845        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
4846    }
4847
4848    #[test]
4849    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
4850        let temp = tempfile::TempDir::new().unwrap();
4851        let source_path = temp.path().join("src/handler.ts");
4852        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4853        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
4854
4855        let coverage_path = temp.path().join("coverage-final.json");
4856        write_single_file_istanbul_fixture(
4857            &coverage_path,
4858            &source_path,
4859            &serde_json::json!({
4860                "0": {
4861                    "name": "handleClick",
4862                    "line": 40,
4863                    "decl": {
4864                        "start": { "line": 22, "column": 13 },
4865                        "end": { "line": 22, "column": 24 }
4866                    },
4867                    "loc": {
4868                        "start": { "line": 40, "column": 27 },
4869                        "end": { "line": 42, "column": 1 }
4870                    }
4871                }
4872            }),
4873            &serde_json::json!({
4874                "0": 1
4875            }),
4876        );
4877
4878        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4879        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4880        let file_coverage = coverage.get(&canonical_source).unwrap();
4881
4882        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
4883        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
4884    }
4885
4886    #[test]
4887    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
4888        let temp = tempfile::TempDir::new().unwrap();
4889        let source_path = temp.path().join("src/actor.ts");
4890        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4891        std::fs::write(
4892            &source_path,
4893            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
4894        )
4895        .unwrap();
4896
4897        let coverage_path = temp.path().join("coverage-final.json");
4898        write_single_file_istanbul_fixture(
4899            &coverage_path,
4900            &source_path,
4901            &serde_json::json!({
4902                "0": {
4903                    "name": "(anonymous_0)",
4904                    "line": 4,
4905                    "decl": {
4906                        "start": { "line": 1, "column": 28 },
4907                        "end": { "line": 4, "column": 26 }
4908                    },
4909                    "loc": {
4910                        "start": { "line": 4, "column": 27 },
4911                        "end": { "line": 6, "column": 1 }
4912                    }
4913                }
4914            }),
4915            &serde_json::json!({
4916                "0": 642
4917            }),
4918        );
4919
4920        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4921        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4922        let file_coverage = coverage.get(&canonical_source).unwrap();
4923
4924        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
4925    }
4926
4927    #[test]
4928    fn istanbul_lookup_exact_match() {
4929        let mut functions = rustc_hash::FxHashMap::default();
4930        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4931        let fc = IstanbulFileCoverage { functions };
4932        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
4933    }
4934
4935    #[test]
4936    fn istanbul_lookup_fuzzy_match_within_offset() {
4937        let mut functions = rustc_hash::FxHashMap::default();
4938        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4939        let fc = IstanbulFileCoverage { functions };
4940        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4941        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4942    }
4943
4944    #[test]
4945    fn istanbul_lookup_fuzzy_match_outside_offset() {
4946        let mut functions = rustc_hash::FxHashMap::default();
4947        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4948        let fc = IstanbulFileCoverage { functions };
4949        assert!(fc.lookup("handleClick", 13, 0).is_none());
4950    }
4951
4952    #[test]
4953    fn istanbul_lookup_name_mismatch() {
4954        let mut functions = rustc_hash::FxHashMap::default();
4955        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4956        let fc = IstanbulFileCoverage { functions };
4957        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
4958    }
4959
4960    #[test]
4961    fn istanbul_lookup_empty() {
4962        let fc = IstanbulFileCoverage {
4963            functions: rustc_hash::FxHashMap::default(),
4964        };
4965        assert!(fc.lookup("anything", 1, 0).is_none());
4966    }
4967
4968    #[test]
4969    fn istanbul_lookup_fuzzy_picks_closest() {
4970        let mut functions = rustc_hash::FxHashMap::default();
4971        functions.insert(("render".to_string(), 8, 0), 60.0);
4972        functions.insert(("render".to_string(), 12, 0), 90.0);
4973        let fc = IstanbulFileCoverage { functions };
4974        let result = fc.lookup("render", 10, 0);
4975        assert!(result.is_some());
4976        let pct = result.unwrap();
4977        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
4978    }
4979
4980    #[test]
4981    fn istanbul_lookup_anonymous_fallback_single_candidate() {
4982        let mut functions = rustc_hash::FxHashMap::default();
4983        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4984        let fc = IstanbulFileCoverage { functions };
4985        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4986        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4987    }
4988
4989    #[test]
4990    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
4991        let mut functions = rustc_hash::FxHashMap::default();
4992        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
4993        let fc = IstanbulFileCoverage { functions };
4994
4995        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
4996    }
4997
4998    #[test]
4999    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
5000        let mut functions = rustc_hash::FxHashMap::default();
5001        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
5002        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
5003        let fc = IstanbulFileCoverage { functions };
5004        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
5005    }
5006
5007    #[test]
5008    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
5009        let mut functions = rustc_hash::FxHashMap::default();
5010        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
5011        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
5012        let fc = IstanbulFileCoverage { functions };
5013        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
5014        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
5015    }
5016
5017    #[test]
5018    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
5019        let mut functions = rustc_hash::FxHashMap::default();
5020        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
5021        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
5022        let fc = IstanbulFileCoverage { functions };
5023        assert!(fc.lookup("myHandler", 28, 0).is_none());
5024    }
5025
5026    #[test]
5027    fn istanbul_lookup_anonymous_fallback_outside_offset() {
5028        let mut functions = rustc_hash::FxHashMap::default();
5029        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
5030        let fc = IstanbulFileCoverage { functions };
5031        assert!(fc.lookup("myHandler", 31, 0).is_none());
5032    }
5033
5034    #[test]
5035    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
5036        let mut functions = rustc_hash::FxHashMap::default();
5037        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
5038        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
5039        let fc = IstanbulFileCoverage { functions };
5040        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
5041    }
5042
5043    #[test]
5044    fn build_test_refs_empty() {
5045        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
5046        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
5047        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
5048        assert!(refs.is_empty());
5049    }
5050
5051    #[test]
5052    fn build_test_refs_empty_inputs() {
5053        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
5054        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
5055        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
5056        assert!(refs.is_empty());
5057    }
5058
5059    #[test]
5060    fn istanbul_crap_empty_complexity() {
5061        let result = istanbul_crap_default(&[], None, false);
5062        assert!((result.max_crap).abs() < f64::EPSILON);
5063        assert_eq!(result.signals.above, 0);
5064        assert_eq!(result.matched, 0);
5065        assert_eq!(result.total, 0);
5066    }
5067
5068    #[test]
5069    fn istanbul_crap_match_statistics() {
5070        let funcs = vec![make_fn_complexity(5), {
5071            let mut f = make_fn_complexity(3);
5072            f.name = "other_fn".into();
5073            f.line = 10;
5074            f
5075        }];
5076        let mut functions = rustc_hash::FxHashMap::default();
5077        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
5078        let file_cov = IstanbulFileCoverage { functions };
5079        let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
5080        assert_eq!(result.matched, 1);
5081        assert_eq!(result.total, 2);
5082    }
5083
5084    #[test]
5085    fn estimated_crap_multiple_functions_mixed_coverage() {
5086        let funcs = vec![
5087            make_fn_complexity(10), // name "test_fn" line 1
5088            {
5089                let mut f = make_fn_complexity(3);
5090                f.name = "helper".into();
5091                f.line = 20;
5092                f
5093            },
5094        ];
5095        let mut refs = rustc_hash::FxHashSet::default();
5096        refs.insert("test_fn".to_string());
5097        let result = estimated_crap_default(
5098            &funcs,
5099            &refs,
5100            true,
5101            fallow_output::CoverageSource::Estimated,
5102        );
5103        let (max, above) = (result.max_crap, result.signals.above);
5104        assert!(max > 10.0);
5105        assert_eq!(above, 0);
5106    }
5107
5108    #[test]
5109    fn binary_crap_test_reachable() {
5110        let funcs = vec![make_fn_complexity(10)];
5111        let (max, above) = compute_crap_scores_binary(&funcs, true);
5112        assert!((max - 10.0).abs() < f64::EPSILON);
5113        assert_eq!(above, 0);
5114    }
5115
5116    #[test]
5117    fn binary_crap_not_reachable() {
5118        let funcs = vec![make_fn_complexity(6)];
5119        let (max, above) = compute_crap_scores_binary(&funcs, false);
5120        assert!((max - 42.0).abs() < f64::EPSILON);
5121        assert_eq!(above, 1);
5122    }
5123
5124    #[test]
5125    fn binary_crap_threshold_boundary() {
5126        let funcs = vec![make_fn_complexity(5)];
5127        let (max, above) = compute_crap_scores_binary(&funcs, false);
5128        assert!((max - 30.0).abs() < f64::EPSILON);
5129        assert_eq!(above, 1);
5130    }
5131
5132    #[test]
5133    fn binary_crap_empty() {
5134        let (max, above) = compute_crap_scores_binary(&[], true);
5135        assert!((max).abs() < f64::EPSILON);
5136        assert_eq!(above, 0);
5137    }
5138
5139    #[test]
5140    fn binary_crap_multiple_functions() {
5141        let funcs = vec![make_fn_complexity(3), make_fn_complexity(8)];
5142        let (max, above) = compute_crap_scores_binary(&funcs, false);
5143        assert!((max - 72.0).abs() < f64::EPSILON);
5144        assert_eq!(above, 1);
5145    }
5146}