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