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