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    /// Analyzed files the coverage map carried an entry for. Read against the
49    /// map's own file count, this separates a map that did not join from code
50    /// the map says nothing ran in.
51    pub(crate) istanbul_files_joined: usize,
52    /// Files the coverage map describes, joined or not. Zero without a map.
53    pub(crate) istanbul_files_total: usize,
54    /// Input format of the coverage map. `None` without a map.
55    pub(crate) coverage_input_format: Option<fallow_output::CoverageInputFormat>,
56    /// Per-file, per-function CRAP data used to emit `--max-crap` findings.
57    /// Absolute paths match `FileHealthScore.path`. Absent entries indicate the
58    /// file had zero functions.
59    pub(crate) per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
60    /// Provenance map for synthetic Angular `<template>` findings whose CRAP
61    /// was inherited from the owning `.component.ts` via the inverse
62    /// `templateUrl` edge. Keys are the template `.html` absolute paths,
63    /// values are the owner `.ts` absolute paths (the path used for the
64    /// `inherited from foo.component.ts` human-output suffix). Absent for
65    /// non-template files and for templates with no `.ts` owner.
66    pub(crate) template_inherit_provenance:
67        rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
68}
69
70struct FileScoreOutputParts<'a> {
71    graph: &'a fallow_graph::graph::ModuleGraph,
72    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
73    results: &'a crate::results::AnalysisResults,
74    scores: Vec<FileHealthScore>,
75    coverage: CoverageGapData,
76    circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
77    top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
78    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
79    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
80    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
81    cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
82    direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
83    istanbul_matched: usize,
84    istanbul_total: usize,
85    istanbul_files_joined: usize,
86    istanbul_files_total: usize,
87    coverage_input_format: Option<fallow_output::CoverageInputFormat>,
88    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
89    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
90}
91
92/// Per-path snapshot of analysis-pipeline findings, retained alongside the
93/// pre-aggregated `analysis_counts` so that workspace- or group-scoped runs
94/// can recompute counts without re-running the full pipeline.
95///
96/// All paths are absolute (matching `AnalysisResults` and `FileHealthScore`).
97#[derive(Clone, Default)]
98pub struct AnalysisCountsSnapshot {
99    /// One entry per unused file.
100    unused_file_paths: Vec<std::path::PathBuf>,
101    /// One entry per unused value or type export, keyed by the file containing
102    /// the export.
103    unused_export_paths: Vec<std::path::PathBuf>,
104    /// One entry per unused dependency across `dependencies`,
105    /// `devDependencies`, and `optionalDependencies`, keyed by the
106    /// `package.json` path that declared it.
107    unused_dep_package_paths: Vec<std::path::PathBuf>,
108    /// Each cycle as the set of file paths it contains. Used to count cycles
109    /// that touch any file inside a workspace.
110    circular_dep_groups: Vec<Vec<std::path::PathBuf>>,
111    /// Total exports per module (`module.exports.len()` in the graph), used
112    /// as the denominator for `dead_export_pct`.
113    module_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
114}
115
116impl AnalysisCountsSnapshot {
117    /// Compute analysis counts for the file subset selected by `subset`.
118    ///
119    /// Returns `*defaults` when `subset.is_full()`. Otherwise recomputes
120    /// every count by retaining paths the subset accepts. Cycles are counted
121    /// when any cycle member is in the subset.
122    ///
123    /// Unused-dep counting is special-cased: dep entries are keyed by their
124    /// `package.json` path, which is never a source file and therefore never
125    /// matches the source-file membership of a `Paths` subset. For
126    /// `SubsetFilter::Paths`, a `package.json` is considered
127    /// in scope when at least one source file in the subset sits inside its
128    /// directory (the dep's owning workspace).
129    ///
130    /// `total_deps` is propagated unchanged from `defaults`; it is not
131    /// available per-subset today (mirrors the project-wide behaviour).
132    pub(crate) fn counts_for(
133        &self,
134        subset: &crate::health::SubsetFilter<'_>,
135        defaults: &crate::vital_signs::AnalysisCounts,
136    ) -> crate::vital_signs::AnalysisCounts {
137        if subset.is_full() {
138            return *defaults;
139        }
140        let dead_files = self
141            .unused_file_paths
142            .iter()
143            .filter(|p| subset.matches(p))
144            .count();
145        let dead_exports = self
146            .unused_export_paths
147            .iter()
148            .filter(|p| subset.matches(p))
149            .count();
150        let unused_deps = self
151            .unused_dep_package_paths
152            .iter()
153            .filter(|dep_path| dep_in_subset(subset, dep_path))
154            .count();
155        let circular_deps = self
156            .circular_dep_groups
157            .iter()
158            .filter(|cycle| cycle.iter().any(|p| subset.matches(p)))
159            .count();
160        let total_exports = self
161            .module_export_counts
162            .iter()
163            .filter(|(p, _)| subset.matches(p))
164            .map(|(_, n)| *n)
165            .sum();
166        crate::vital_signs::AnalysisCounts {
167            total_exports,
168            dead_files,
169            dead_exports,
170            unused_deps,
171            circular_deps,
172            total_deps: defaults.total_deps,
173        }
174    }
175}
176
177/// Return true when an unused dependency's `package.json` path belongs to
178/// the subset.
179///
180/// For [`crate::health::SubsetFilter::Paths`] the dep's containing workspace
181/// (its `package.json` parent directory) is considered in scope when at
182/// least one source file in the subset lives under that directory.
183fn dep_in_subset(subset: &crate::health::SubsetFilter<'_>, dep_path: &std::path::Path) -> bool {
184    match subset {
185        crate::health::SubsetFilter::Full => true,
186        crate::health::SubsetFilter::Paths(set) => {
187            let Some(workspace_root) = dep_path.parent() else {
188                return false;
189            };
190            set.iter().any(|p| p.starts_with(workspace_root))
191        }
192    }
193}
194
195/// Aggregate complexity totals from a parsed module.
196///
197/// Returns `(total_cyclomatic, total_cognitive, function_count, lines)`.
198///
199/// Every unit counts, including the synthetic `<module>` unit that carries the
200/// file's module-scope decision points. `function_count` therefore counts a
201/// non-function unit on files that branch outside every function, which is the
202/// point: complexity density measured against a total that skipped module
203/// scope understated the file.
204#[expect(
205    clippy::cast_possible_truncation,
206    reason = "line count is bounded by source file size"
207)]
208fn aggregate_complexity(module: &crate::source::ModuleInfo) -> (u32, u32, usize, u32) {
209    let cyc: u32 = module
210        .complexity
211        .iter()
212        .map(|f| u32::from(f.cyclomatic))
213        .sum();
214    let cog: u32 = module
215        .complexity
216        .iter()
217        .map(|f| u32::from(f.cognitive))
218        .sum();
219    let funcs = module.complexity.len();
220    let lines = module.line_offsets.len() as u32;
221    (cyc, cog, funcs, lines)
222}
223
224/// Compute the dead code ratio for a single file.
225///
226/// Returns the fraction of VALUE exports with zero references (0.0-1.0).
227/// Type-only exports (interfaces, type aliases) are excluded from both
228/// numerator and denominator to avoid inflating the ratio for well-typed
229/// codebases. Returns 1.0 if the entire file is unused, 0.0 if it has no
230/// value exports.
231fn compute_dead_code_ratio(
232    path: &std::path::Path,
233    exports: &[fallow_graph::graph::ExportSymbol],
234    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
235    unused_exports_by_path: &rustc_hash::FxHashMap<&std::path::Path, usize>,
236) -> f64 {
237    if unused_files.contains(path) {
238        return 1.0;
239    }
240    let value_exports = exports.iter().filter(|e| !e.is_type_only).count();
241    if value_exports == 0 {
242        return 0.0;
243    }
244    let unused = unused_exports_by_path.get(path).copied().unwrap_or(0);
245    (unused as f64 / value_exports as f64).min(1.0)
246}
247
248/// Compute complexity density: total cyclomatic / lines of code.
249///
250/// Returns 0.0 when the file has no lines.
251fn compute_complexity_density(total_cyclomatic: u32, lines: u32) -> f64 {
252    if lines > 0 {
253        f64::from(total_cyclomatic) / f64::from(lines)
254    } else {
255        0.0
256    }
257}
258
259/// CRAP score threshold (inclusive). CC=5 untested gives exactly 30 (5^2 + 5),
260/// matching the canonical CRAP threshold from Savoia & Evans (2007).
261pub(super) const CRAP_THRESHOLD: f64 = 30.0;
262
263/// Effective-threshold inputs for the CRAP columns of file scoring: the run
264/// resolver plus the `enforce_crap` bit from the health scope.
265#[derive(Clone, Copy)]
266pub(super) struct CrapScoreThresholds<'a> {
267    pub(super) resolver: &'a ThresholdOverrideResolver,
268    pub(super) enforce_crap: bool,
269}
270
271/// Per-function effective-CRAP-ceiling lookup for one file's scoring loop:
272/// the run resolver bound to the file's project-relative path, so the CRAP
273/// loops can resolve each function's ceiling where the function name is in
274/// hand. `enforce_crap` mirrors `HealthScope::enforce_crap`: a global ceiling
275/// of `0` disables CRAP enforcement, so every function counts as exempt at the
276/// canonical baseline instead of breaching a degenerate `0` ceiling.
277pub(super) struct CrapCeilingLookup<'a> {
278    resolver: &'a ThresholdOverrideResolver,
279    relative: &'a std::path::Path,
280    enforce_crap: bool,
281}
282
283/// Threshold-relative CRAP signals accumulated over one file's functions.
284///
285/// `above` and `exempted` count the ROUNDED per-function CRAP value, the same
286/// value the findings pipeline compares against the effective ceiling, so a
287/// boundary function cannot produce a finding without being counted here, or
288/// be counted here without producing a finding.
289#[derive(Debug, Default)]
290struct CrapThresholdSignals {
291    /// Functions whose rounded CRAP meets or exceeds their effective ceiling.
292    above: usize,
293    /// Functions at or above the canonical 30.0 baseline whose effective
294    /// ceiling exempts them (all baseline-breaching functions when CRAP
295    /// enforcement is disabled).
296    exempted: usize,
297    /// Lowest effective ceiling among the observed functions.
298    min_ceiling: Option<f64>,
299}
300
301impl<'a> CrapCeilingLookup<'a> {
302    pub(super) fn new(thresholds: CrapScoreThresholds<'a>, relative: &'a std::path::Path) -> Self {
303        Self {
304            resolver: thresholds.resolver,
305            relative,
306            enforce_crap: thresholds.enforce_crap,
307        }
308    }
309
310    /// Fold one function's rounded CRAP value into the file's
311    /// threshold-relative signals.
312    fn observe(&self, function: &str, crap_rounded: f64, signals: &mut CrapThresholdSignals) {
313        let ceiling = self.resolver.effective_max_crap(self.relative, function);
314        signals.min_ceiling = Some(signals.min_ceiling.map_or(ceiling, |m| m.min(ceiling)));
315        if !self.enforce_crap {
316            if crap_rounded >= CRAP_THRESHOLD {
317                signals.exempted += 1;
318            }
319        } else if crap_rounded >= ceiling {
320            signals.above += 1;
321        } else if crap_rounded >= CRAP_THRESHOLD {
322            signals.exempted += 1;
323        }
324    }
325}
326
327/// Per-function CRAP data used to emit `--max-crap` findings.
328#[derive(Debug, Clone, Copy)]
329pub struct PerFunctionCrap {
330    /// 1-based line number of the function's definition.
331    pub(crate) line: u32,
332    /// 0-based column of the function's definition. Required alongside `line`
333    /// to disambiguate curried arrows that share a start line, e.g.
334    /// `(x) => (y) => {...}`. Without `col`, two `PerFunctionCrap` entries
335    /// would collide in the (path, line) finding index and one function's
336    /// CRAP score could be attached to another function's identity.
337    pub(crate) col: u32,
338    /// Computed CRAP score, rounded to one decimal place.
339    pub(crate) crap: f64,
340    /// Coverage percentage used to compute `crap`, when Istanbul matched the
341    /// function. `None` for estimated coverage or unmatched functions.
342    pub(crate) coverage_pct: Option<f64>,
343    /// Bucketed coverage tier used to drive action selection in JSON output.
344    /// Populated for both Istanbul-matched and estimated CRAP rows so the
345    /// action builder does not need to recompute reachability state.
346    pub(crate) coverage_tier: fallow_output::CoverageTier,
347    /// Provenance of `coverage_tier` and `crap`. `Istanbul` for direct fnMap
348    /// matches, `Estimated` for graph-based fallbacks against the finding's
349    /// own file, `EstimatedComponentInherited` for the template-inherit path
350    /// that reaches the owning Angular `.component.ts` through the inverse
351    /// `templateUrl` edge. Threaded into `ComplexityViolation.coverage_source` by
352    /// `merge_crap_findings`.
353    pub(crate) coverage_source: fallow_output::CoverageSource,
354}
355
356/// Istanbul CRAP result: CRAP scores plus match statistics.
357#[derive(Debug)]
358struct IstanbulCrapResult {
359    pub max_crap: f64,
360    /// Threshold-relative counts and the lowest effective ceiling.
361    pub signals: CrapThresholdSignals,
362    /// Functions that found a match in Istanbul data.
363    pub matched: usize,
364    /// Total functions evaluated.
365    pub total: usize,
366    /// Per-function CRAP data indexed by function position within `complexity`.
367    pub per_function: Vec<PerFunctionCrap>,
368}
369
370/// Compute per-function CRAP scores using Istanbul coverage data.
371///
372/// For each function, looks up its per-function statement coverage percentage
373/// from the Istanbul data and applies the canonical CRAP formula:
374/// `CRAP = CC^2 * (1 - cov/100)^3 + CC`
375///
376/// Functions not found in the coverage data fall back to the estimated model
377/// using the file's test-reachability status.
378///
379/// Returns CRAP scores and match statistics for reporting.
380fn compute_crap_scores_istanbul(
381    complexity: &[fallow_types::extract::FunctionComplexity],
382    file_coverage: Option<&IstanbulFileCoverage>,
383    is_test_reachable: bool,
384    ceilings: &CrapCeilingLookup<'_>,
385) -> IstanbulCrapResult {
386    if complexity.is_empty() {
387        return IstanbulCrapResult {
388            max_crap: 0.0,
389            signals: CrapThresholdSignals::default(),
390            matched: 0,
391            total: 0,
392            per_function: Vec::new(),
393        };
394    }
395    let mut max = 0.0_f64;
396    let mut signals = CrapThresholdSignals::default();
397    let mut matched = 0usize;
398    let mut total = 0usize;
399    let mut per_function = Vec::with_capacity(complexity.len());
400    for f in complexity {
401        // Synthetic template-family units carry no measurable coverage (an
402        // Istanbul fnMap can never contain them), so they are excluded from
403        // the CRAP dimension entirely: no per-function entry, no max /
404        // above-threshold contribution, no match-statistics slot. The
405        // module-scope unit leaves for the same reason plus one more: it has no
406        // fnMap identity to match, so counting it would score every branching
407        // file as one more uncovered high-CRAP unit and drag the reported
408        // Istanbul match rate down.
409        if fallow_types::extract::is_synthetic_template_unit(&f.name)
410            || fallow_types::extract::is_synthetic_module_unit(&f.name)
411        {
412            continue;
413        }
414        total += 1;
415        let (crap, coverage_pct, tier, source) =
416            crap_for_function(f, file_coverage, is_test_reachable, &mut matched);
417        let crap_rounded = (crap * 10.0).round() / 10.0;
418        max = max.max(crap);
419        ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
420        per_function.push(PerFunctionCrap {
421            line: f.line,
422            col: f.col,
423            crap: crap_rounded,
424            coverage_pct,
425            coverage_tier: tier,
426            coverage_source: source,
427        });
428    }
429    IstanbulCrapResult {
430        max_crap: (max * 10.0).round() / 10.0,
431        signals,
432        matched,
433        total,
434        per_function,
435    }
436}
437
438/// Resolve one function's `(crap, coverage_pct, tier, source)` from Istanbul
439/// coverage, falling back to the test-reachability estimate model. Increments
440/// `matched` when a real coverage value is found.
441#[expect(
442    clippy::suboptimal_flops,
443    reason = "cc * cc + cc matches the CRAP formula specification"
444)]
445fn crap_for_function(
446    f: &fallow_types::extract::FunctionComplexity,
447    file_coverage: Option<&IstanbulFileCoverage>,
448    is_test_reachable: bool,
449    matched: &mut usize,
450) -> (
451    f64,
452    Option<f64>,
453    fallow_output::CoverageTier,
454    fallow_output::CoverageSource,
455) {
456    let cc = f64::from(f.cyclomatic);
457    let lookup = file_coverage.and_then(|fc| fc.lookup_function(f));
458    if let Some(cov_pct) = lookup {
459        *matched += 1;
460        return (
461            crap_formula(cc, cov_pct),
462            Some(cov_pct),
463            fallow_output::CoverageTier::from_pct(cov_pct),
464            fallow_output::CoverageSource::Istanbul,
465        );
466    }
467    // The same static estimate the run without a coverage map would have
468    // used. A map that says nothing about this function is not evidence that
469    // it ran, so passing one must never lower its CRAP below the estimate.
470    if is_test_reachable {
471        return (
472            crap_formula(cc, INDIRECT_TEST_COVERAGE_ESTIMATE),
473            None,
474            fallow_output::CoverageTier::from_pct(INDIRECT_TEST_COVERAGE_ESTIMATE),
475            fallow_output::CoverageSource::Estimated,
476        );
477    }
478    (
479        cc * cc + cc,
480        None,
481        fallow_output::CoverageTier::None,
482        fallow_output::CoverageSource::Estimated,
483    )
484}
485
486/// Estimated coverage for functions directly referenced by test-reachable modules.
487/// An export imported in a test file likely exercises most of the function body.
488const DIRECT_TEST_COVERAGE_ESTIMATE: f64 = 85.0;
489
490/// Estimated coverage for functions in test-reachable files but not directly
491/// referenced by tests. The file is imported by tests, so the function may
492/// be exercised indirectly, but with lower confidence.
493const INDIRECT_TEST_COVERAGE_ESTIMATE: f64 = 40.0;
494const MAX_DIRECT_CALLER_EVIDENCE: usize = 5;
495
496/// Compute per-function CRAP scores using graph-based coverage estimation.
497///
498/// For each function, estimates coverage from the module graph:
499/// - Function name matches an export with test-reachable references: 85%
500/// - File is test-reachable but function not directly referenced: 40%
501/// - File is not test-reachable at all: 0%
502///
503/// Applies the canonical CRAP formula with these estimates.
504/// Returns `(max_crap, count_above_threshold)`.
505/// Estimated CRAP result: score aggregates plus per-function data.
506#[derive(Debug)]
507struct EstimatedCrapResult {
508    pub max_crap: f64,
509    /// Threshold-relative counts and the lowest effective ceiling.
510    pub signals: CrapThresholdSignals,
511    pub per_function: Vec<PerFunctionCrap>,
512}
513
514fn compute_crap_scores_estimated(
515    complexity: &[fallow_types::extract::FunctionComplexity],
516    test_referenced_exports: &rustc_hash::FxHashSet<String>,
517    is_test_reachable: bool,
518    coverage_source: fallow_output::CoverageSource,
519    ceilings: &CrapCeilingLookup<'_>,
520) -> EstimatedCrapResult {
521    if complexity.is_empty() {
522        return EstimatedCrapResult {
523            max_crap: 0.0,
524            signals: CrapThresholdSignals::default(),
525            per_function: Vec::new(),
526        };
527    }
528    let mut max = 0.0_f64;
529    let mut signals = CrapThresholdSignals::default();
530    let mut per_function = Vec::with_capacity(complexity.len());
531    for f in complexity {
532        // Template-family units leave the CRAP dimension: their name never
533        // appears in `test_referenced_exports`, so the estimate could only
534        // ever restate the file's reachability as a disguised cyclomatic gate.
535        // The module-scope unit is not an export either, so it leaves too.
536        if fallow_types::extract::is_synthetic_template_unit(&f.name)
537            || fallow_types::extract::is_synthetic_module_unit(&f.name)
538        {
539            continue;
540        }
541        let cc = f64::from(f.cyclomatic);
542        let estimated_coverage = if test_referenced_exports.contains(f.name.as_str()) {
543            DIRECT_TEST_COVERAGE_ESTIMATE
544        } else if is_test_reachable {
545            INDIRECT_TEST_COVERAGE_ESTIMATE
546        } else {
547            0.0
548        };
549        let crap = crap_formula(cc, estimated_coverage);
550        let crap_rounded = (crap * 10.0).round() / 10.0;
551        max = max.max(crap);
552        ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
553        per_function.push(PerFunctionCrap {
554            line: f.line,
555            col: f.col,
556            crap: crap_rounded,
557            coverage_pct: None,
558            coverage_tier: fallow_output::CoverageTier::from_pct(estimated_coverage),
559            coverage_source,
560        });
561    }
562    EstimatedCrapResult {
563        max_crap: (max * 10.0).round() / 10.0,
564        signals,
565        per_function,
566    }
567}
568
569/// Inherited CRAP context for a synthetic `<template>` finding on an Angular
570/// `.html` template. Populated by `build_template_inherit_contexts` for every
571/// `.html` module that has a `<template>` `FunctionComplexity` entry AND is
572/// reached by at least one non-test `.ts` importer via the `templateUrl`
573/// `SideEffect` edge.
574///
575/// The reachability bit is the OR across all non-test `.ts` owners (any
576/// tested owner makes the template tested); the `test_referenced_exports`
577/// set is the union of each owner's directly-test-referenced export names;
578/// the provenance path points at the chosen owner for human output. When
579/// multiple owners exist, prefer the first test-reachable one so the
580/// "inherited from" suffix points at a meaningful owner rather than an
581/// arbitrary first match.
582#[derive(Debug, Clone)]
583pub(super) struct TemplateInheritContext {
584    pub is_test_reachable: bool,
585    pub test_referenced_exports: rustc_hash::FxHashSet<String>,
586    /// The owning `.ts` file path used for human-output provenance
587    /// (`coverage: partial (inherited from foo.component.ts)`). Set to the
588    /// first test-reachable owner when one exists, otherwise the first
589    /// non-test owner. Absolute path; the human formatter strips it.
590    pub provenance_owner: std::path::PathBuf,
591}
592
593/// Build the inverse `templateUrl` redirect map: for every `.html` module
594/// carrying a synthetic `<template>` `FunctionComplexity` entry, walk
595/// `reverse_deps` to find every `.ts` (or `.component.ts`) importer that is
596/// NOT a test entry point, and compute an aggregate `TemplateInheritContext`
597/// that the CRAP scoring loop can use to redirect reachability + test refs
598/// to the owning component file.
599///
600/// Test-file owners are excluded because Angular spec files do not declare
601/// `templateUrl`; if a `.spec.ts` is the only importer of a `.html`, the
602/// template is genuinely orphaned and the existing fallback (estimated
603/// against the `.html`'s own reachability) is the right answer.
604///
605/// The `.ts` / `.tsx` / `.mts` / `.cts` extension gate intentionally lets
606/// `.d.ts` ambient declarations through, but Angular component classes are
607/// not emitted into `.d.ts` files (which model APIs, not runtime behaviour)
608/// and `templateUrl` SideEffect edges flow only from concrete `@Component`
609/// decorators. A `.d.ts` importer of a `.html` would be a structural
610/// anomaly upstream, not a meaningful owner, so the gate stays simple.
611///
612/// Templates with zero non-test `.ts` owners receive no entry, so the
613/// scoring loop falls through to the existing path unchanged.
614fn build_template_inherit_contexts(
615    graph: &fallow_graph::graph::ModuleGraph,
616    test_coverage: StaticTestCoverage<'_>,
617    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
618    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
619) -> rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext> {
620    let mut out = rustc_hash::FxHashMap::default();
621    for node in &graph.modules {
622        if let Some(context) =
623            template_inherit_context_for_node(node, graph, test_coverage, module_by_id, file_paths)
624        {
625            out.insert(node.file_id, context);
626        }
627    }
628    out
629}
630
631fn template_inherit_context_for_node(
632    node: &fallow_graph::graph::ModuleNode,
633    graph: &fallow_graph::graph::ModuleGraph,
634    test_coverage: StaticTestCoverage<'_>,
635    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
636    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
637) -> Option<TemplateInheritContext> {
638    if !is_template_inherit_candidate(node, module_by_id, file_paths) {
639        return None;
640    }
641    let importers = graph.reverse_deps.get(node.file_id.0 as usize)?;
642    template_inherit_context_from_importers(
643        importers,
644        graph,
645        test_coverage,
646        module_by_id,
647        file_paths,
648    )
649}
650
651fn is_template_inherit_candidate(
652    node: &fallow_graph::graph::ModuleNode,
653    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
654    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
655) -> bool {
656    let Some(path) = file_paths.get(&node.file_id) else {
657        return false;
658    };
659    if !path
660        .extension()
661        .and_then(|ext| ext.to_str())
662        .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
663    {
664        return false;
665    }
666    module_by_id.get(&node.file_id).is_some_and(|module| {
667        module
668            .complexity
669            .iter()
670            .any(|finding| finding.name.as_str() == "<template>")
671    })
672}
673
674fn template_inherit_context_from_importers(
675    importers: &[crate::discover::FileId],
676    graph: &fallow_graph::graph::ModuleGraph,
677    test_coverage: StaticTestCoverage<'_>,
678    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
679    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
680) -> Option<TemplateInheritContext> {
681    let mut any_reachable = false;
682    let mut combined_refs = rustc_hash::FxHashSet::default();
683    let mut provenance: Option<std::path::PathBuf> = None;
684    let mut first_owner: Option<std::path::PathBuf> = None;
685
686    for &importer_id in importers {
687        let Some((owner_node, owner_path)) =
688            template_owner(importer_id, graph, module_by_id, file_paths)
689        else {
690            continue;
691        };
692        if first_owner.is_none() {
693            first_owner = Some((*owner_path).clone());
694        }
695        if test_coverage.covers_file(owner_node.file_id) {
696            any_reachable = true;
697            provenance.get_or_insert_with(|| (*owner_path).clone());
698            let refs = build_test_referenced_exports(&owner_node.exports, test_coverage);
699            combined_refs.extend(refs);
700        }
701    }
702
703    let provenance_owner = provenance.or(first_owner)?;
704    Some(TemplateInheritContext {
705        is_test_reachable: any_reachable,
706        test_referenced_exports: combined_refs,
707        provenance_owner,
708    })
709}
710
711fn template_owner<'a>(
712    importer_id: crate::discover::FileId,
713    graph: &'a fallow_graph::graph::ModuleGraph,
714    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
715    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
716) -> Option<(&'a fallow_graph::graph::ModuleNode, &'a std::path::PathBuf)> {
717    let owner_node = graph.modules.get(importer_id.0 as usize)?;
718    let owner_path = *file_paths.get(&importer_id)?;
719    if !is_template_owner_path(owner_path) || graph.test_entry_points.contains(&importer_id) {
720        return None;
721    }
722    let owner_has_component = module_by_id
723        .get(&importer_id)
724        .is_some_and(|module| module.has_angular_component_template_url);
725    owner_has_component.then_some((owner_node, owner_path))
726}
727
728fn is_template_owner_path(path: &std::path::Path) -> bool {
729    path.extension()
730        .and_then(|ext| ext.to_str())
731        .is_some_and(|ext| {
732            matches!(
733                ext.to_ascii_lowercase().as_str(),
734                "ts" | "tsx" | "mts" | "cts"
735            )
736        })
737}
738
739/// Build the set of export names that have at least one test-reachable reference.
740///
741/// This is the per-function signal: if an export named "foo" has a reference from
742/// a test-reachable module, the function "foo" is considered directly tested.
743fn build_test_referenced_exports(
744    exports: &[fallow_graph::graph::ExportSymbol],
745    test_coverage: StaticTestCoverage<'_>,
746) -> rustc_hash::FxHashSet<String> {
747    let mut set = rustc_hash::FxHashSet::default();
748    for export in exports {
749        if export.is_type_only {
750            continue;
751        }
752        let has_test_ref = test_coverage.covers_any_reference(export);
753        if has_test_ref {
754            set.insert(export.name.to_string());
755        }
756    }
757    set
758}
759
760fn collect_direct_callers(
761    graph: &fallow_graph::graph::ModuleGraph,
762    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
763) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>> {
764    let mut callers_by_target = rustc_hash::FxHashMap::default();
765    for node in &graph.modules {
766        let Some(target_path) = file_paths.get(&node.file_id) else {
767            continue;
768        };
769        let mut callers = graph
770            .direct_importer_summaries(node.file_id)
771            .into_iter()
772            .filter_map(|summary| {
773                file_paths
774                    .get(&summary.source)
775                    .map(|caller_path| DirectCallerEvidence {
776                        path: (*caller_path).clone(),
777                        symbols: summary
778                            .symbols
779                            .into_iter()
780                            .map(|symbol| DirectCallerSymbolEvidence {
781                                imported: symbol.imported,
782                                local: symbol.local,
783                                type_only: symbol.type_only,
784                            })
785                            .collect(),
786                    })
787            })
788            .collect::<Vec<_>>();
789        callers.sort_by(|a, b| a.path.cmp(&b.path));
790        callers.truncate(MAX_DIRECT_CALLER_EVIDENCE);
791        if !callers.is_empty() {
792            callers_by_target.insert((*target_path).clone(), callers);
793        }
794    }
795    callers_by_target
796}
797
798/// Canonical CRAP formula: `CC^2 * (1 - cov/100)^3 + CC`.
799/// At 100% coverage: CRAP = CC. At 0% coverage: CRAP = CC^2 + CC.
800#[expect(
801    clippy::suboptimal_flops,
802    reason = "explicit multiplication matches the CRAP formula specification"
803)]
804fn crap_formula(cc: f64, coverage_pct: f64) -> f64 {
805    let uncovered = 1.0 - coverage_pct / 100.0;
806    cc * cc * uncovered * uncovered * uncovered + cc
807}
808
809/// Maximum column drift tolerated when the anonymous-by-position fallback
810/// matches a candidate on a nearby line. Wide enough to accept curried arrows
811/// and chained callbacks that share a leading indent, tight enough to reject
812/// `function foo()` at column 0 when the only candidate is a multiline-arrow
813/// declaration alias at the typical `const x = async (` column.
814const ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT: u32 = 16;
815
816/// Maximum line drift tolerated by the name-fuzzy and anonymous fallbacks.
817/// Both reject an alias further than this from the target, which is what lets
818/// a lookup binary-search a window of `IstanbulFileCoverage::alias_lines`
819/// instead of scanning every record in the file.
820const ALIAS_FUZZ_MAX_LINE_DRIFT: u32 = 2;
821
822#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
823struct IstanbulPosition {
824    line: u32,
825    col: u32,
826}
827
828impl IstanbulPosition {
829    const fn new(line: u32, col: u32) -> Self {
830        Self { line, col }
831    }
832
833    const fn distance_from(self, target: Self) -> (u32, u32) {
834        (
835            self.line.abs_diff(target.line),
836            self.col.abs_diff(target.col),
837        )
838    }
839}
840
841#[derive(Clone, Copy, Debug, Eq, PartialEq)]
842struct IstanbulSpan {
843    start: IstanbulPosition,
844    end: IstanbulPosition,
845}
846
847impl IstanbulSpan {
848    fn from_entry(
849        fn_entry: &oxc_coverage_instrument::FnEntry,
850        source_index: Option<&IstanbulSourceIndex<'_>>,
851    ) -> Option<Self> {
852        let start = normalized_istanbul_position(
853            fn_entry.loc.start.line,
854            fn_entry.loc.start.column,
855            source_index,
856        )?;
857        let end = normalized_istanbul_position(
858            fn_entry.loc.end.line,
859            fn_entry.loc.end.column,
860            source_index,
861        )?;
862        (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
863    }
864
865    fn header_from_entry(
866        fn_entry: &oxc_coverage_instrument::FnEntry,
867        source_index: Option<&IstanbulSourceIndex<'_>>,
868    ) -> Option<Self> {
869        let start = normalized_istanbul_position(
870            fn_entry.decl.start.line,
871            fn_entry.decl.start.column,
872            source_index,
873        )?;
874        let end = normalized_istanbul_position(
875            fn_entry.loc.start.line,
876            fn_entry.loc.start.column,
877            source_index,
878        )?;
879        (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
880    }
881
882    /// Half-open containment: `end` is the position just past the body.
883    fn contains(self, position: IstanbulPosition) -> bool {
884        self.start <= position && position < self.end
885    }
886
887    fn strictly_contains(self, other: Self) -> bool {
888        self != other && self.start <= other.start && other.end <= self.end
889    }
890}
891
892/// A position under which an Istanbul function record can be found.
893///
894/// The producer's effective position and `decl.start` are primary: they
895/// identify the function itself. The body start (`loc.start`) is secondary.
896/// istanbul-lib-instrument records an expression-bodied arrow's `loc` as the
897/// body expression, so for curried arrows the outer entry's body start is the
898/// inner entry's declaration start. A secondary alias therefore yields to a
899/// primary alias at the same position instead of making it ambiguous.
900#[derive(Clone, Copy, Debug, Eq, PartialEq)]
901struct IstanbulAlias {
902    position: IstanbulPosition,
903    primary: bool,
904}
905
906/// How many primary and secondary aliases share one position within a
907/// collision namespace (per name, or across all anonymous records).
908#[derive(Clone, Copy, Debug, Default)]
909struct IstanbulAliasCounts {
910    primary: usize,
911    secondary: usize,
912}
913
914impl IstanbulAliasCounts {
915    fn add(&mut self, alias: IstanbulAlias) {
916        if alias.primary {
917            self.primary += 1;
918        } else {
919            self.secondary += 1;
920        }
921    }
922
923    /// A primary alias is unique when no other primary shares its position;
924    /// a secondary alias is unique only when nothing else does.
925    const fn is_unique(self, alias: IstanbulAlias) -> bool {
926        if alias.primary {
927            self.primary == 1
928        } else {
929            self.primary == 0 && self.secondary == 1
930        }
931    }
932
933    const fn has_secondary_only_collision(self) -> bool {
934        self.primary == 0 && self.secondary > 1
935    }
936}
937
938fn is_anonymous_istanbul_name(name: &str) -> bool {
939    name.starts_with("(anonymous_")
940}
941
942struct IstanbulFunctionCoverage {
943    name: String,
944    coverage_pct: f64,
945    aliases: Vec<IstanbulAlias>,
946    /// Where the producer says the function is written. Unlike the alias set
947    /// this is never a synthesized position, so it answers "is another
948    /// function declared here" without false positives.
949    decl_start: IstanbulPosition,
950    /// Whether another function is declared inside this record's header span.
951    /// Computed once per file, because the header fallback needs it on every
952    /// unmatched lookup and recomputing it there is quadratic.
953    header_holds_other_fn: bool,
954    header_span: Option<IstanbulSpan>,
955    body_span: Option<IstanbulSpan>,
956}
957
958impl IstanbulFunctionCoverage {
959    /// Whether this record answers to `name`, either as recorded or as the
960    /// property behind an accessor keyword the producer kept.
961    fn answers_to(&self, name: &str) -> bool {
962        if self.name == name {
963            return true;
964        }
965        accessor_property_name(&self.name) == Some(name)
966    }
967
968    fn nearest_alias(
969        &self,
970        target: IstanbulPosition,
971        max_column_drift: Option<u32>,
972    ) -> Option<(u32, u32)> {
973        self.aliases
974            .iter()
975            .filter_map(|alias| {
976                let distance = alias.position.distance_from(target);
977                if distance.0 > ALIAS_FUZZ_MAX_LINE_DRIFT {
978                    return None;
979                }
980                if distance.0 > 0 && max_column_drift.is_some_and(|maximum| distance.1 > maximum) {
981                    return None;
982                }
983                Some(distance)
984            })
985            .min()
986    }
987}
988
989/// Pre-processed per-function coverage data for a single file,
990/// derived from Istanbul `coverage-final.json`.
991pub struct IstanbulFileCoverage {
992    /// One record per Istanbul `fnMap` identity. Each record owns its
993    /// deduplicated producer, declaration, and valid body-start aliases, so an
994    /// entry cannot tie with itself during anonymous positional matching.
995    functions: Vec<IstanbulFunctionCoverage>,
996    /// Exact aliases that belong to one function identity. Primary aliases
997    /// that collide are removed from this index and recorded in
998    /// `ambiguous_aliases`. A secondary alias yields to a primary collision,
999    /// while secondary-only collisions are also recorded as ambiguous.
1000    alias_index: rustc_hash::FxHashMap<(String, u32, u32), usize>,
1001    /// Aliases shared by multiple function identities without a unique
1002    /// primary owner. Exact queries at these positions abstain instead of
1003    /// falling through to a fuzzy attribution.
1004    ambiguous_aliases: rustc_hash::FxHashSet<(String, u32, u32)>,
1005    /// Positions shared by multiple anonymous identities without a unique
1006    /// primary owner. Anonymous fallback abstains at these targets even when
1007    /// each identity has other aliases.
1008    ambiguous_anonymous_aliases: rustc_hash::FxHashSet<IstanbulPosition>,
1009    /// `(alias line, function index)` for every retained alias, sorted.
1010    /// The fuzzy and anonymous fallbacks reject any alias further than
1011    /// [`ALIAS_FUZZ_MAX_LINE_DRIFT`] lines from the target, so they can search
1012    /// a window of this index instead of every record in the file.
1013    alias_lines: Vec<(u32, usize)>,
1014    /// `(header span start line, function index)` for every record that has a
1015    /// header span, sorted.
1016    header_starts: Vec<(u32, usize)>,
1017    /// Height in lines of the tallest header span in this file. A span reaches
1018    /// no further than this below its own start, which bounds the window
1019    /// searched in `header_starts`.
1020    max_header_height: u32,
1021    /// The coverage map was recorded against a different checkout of the
1022    /// project, so line numbers may have drifted beyond the bounded fuzz.
1023    /// Enables the distance-free unambiguous-name fallback in [`Self::lookup`].
1024    relocated: bool,
1025}
1026
1027/// Records whether each header span has a second function declared inside it,
1028/// which makes the span say nothing about a position it contains.
1029///
1030/// Computed once per file because the header fallback needs the answer on
1031/// every unmatched lookup and recomputing it there would be quadratic.
1032fn mark_headers_holding_other_fns(functions: &mut [IstanbulFunctionCoverage]) {
1033    let mut declarations: Vec<IstanbulPosition> = functions
1034        .iter()
1035        .map(|function| function.decl_start)
1036        .collect();
1037    declarations.sort_unstable();
1038    for function in functions {
1039        let Some(span) = function.header_span else {
1040            continue;
1041        };
1042        // The record's own declaration opens its header span, so it is always
1043        // in range: a second hit is what marks a foreign function.
1044        let first = declarations.partition_point(|position| *position < span.start);
1045        let past = declarations.partition_point(|position| *position < span.end);
1046        function.header_holds_other_fn = past - first > 1;
1047    }
1048}
1049
1050/// Line-keyed indexes over one file's records, built once per file so the
1051/// fuzzy, anonymous, and header fallbacks can binary-search a bounded window
1052/// instead of rescanning every record on every unmatched lookup.
1053struct IstanbulLineIndexes {
1054    alias_lines: Vec<(u32, usize)>,
1055    header_starts: Vec<(u32, usize)>,
1056    max_header_height: u32,
1057}
1058
1059impl IstanbulLineIndexes {
1060    fn build(functions: &[IstanbulFunctionCoverage]) -> Self {
1061        let mut alias_lines: Vec<(u32, usize)> = functions
1062            .iter()
1063            .enumerate()
1064            .flat_map(|(function_index, function)| {
1065                function
1066                    .aliases
1067                    .iter()
1068                    .map(move |alias| (alias.position.line, function_index))
1069            })
1070            .collect();
1071        alias_lines.sort_unstable();
1072
1073        let mut header_starts: Vec<(u32, usize)> = Vec::new();
1074        let mut max_header_height = 0;
1075        for (function_index, function) in functions.iter().enumerate() {
1076            if let Some(span) = function.header_span {
1077                header_starts.push((span.start.line, function_index));
1078                // `IstanbulSpan` construction requires `start < end`, so the
1079                // end line is never above the start line.
1080                max_header_height = max_header_height.max(span.end.line - span.start.line);
1081            }
1082        }
1083        header_starts.sort_unstable();
1084
1085        Self {
1086            alias_lines,
1087            header_starts,
1088            max_header_height,
1089        }
1090    }
1091}
1092
1093impl IstanbulFileCoverage {
1094    /// Coverage for an extracted unit.
1095    ///
1096    /// The entry point callers outside this module get, because the
1097    /// private-member rule has to hold for every consumer: gating it at one
1098    /// call site leaves the next one free to reintroduce the bug.
1099    pub fn lookup_function(
1100        &self,
1101        function: &fallow_types::extract::FunctionComplexity,
1102    ) -> Option<f64> {
1103        if function.is_private_member {
1104            return None;
1105        }
1106        self.lookup(function.name.as_str(), function.line, function.col)
1107    }
1108
1109    fn new(mut functions: Vec<IstanbulFunctionCoverage>, relocated: bool) -> Self {
1110        let mut named_alias_counts: rustc_hash::FxHashMap<
1111            (String, IstanbulPosition),
1112            IstanbulAliasCounts,
1113        > = rustc_hash::FxHashMap::default();
1114        let mut anonymous_alias_counts: rustc_hash::FxHashMap<
1115            IstanbulPosition,
1116            IstanbulAliasCounts,
1117        > = rustc_hash::FxHashMap::default();
1118        for function in &functions {
1119            let is_anonymous = is_anonymous_istanbul_name(&function.name);
1120            for alias in &function.aliases {
1121                named_alias_counts
1122                    .entry((function.name.clone(), alias.position))
1123                    .or_default()
1124                    .add(*alias);
1125                if is_anonymous {
1126                    anonymous_alias_counts
1127                        .entry(alias.position)
1128                        .or_default()
1129                        .add(*alias);
1130                }
1131            }
1132        }
1133
1134        // A secondary alias that collides with a primary is dropped, which
1135        // leaves the primary owner of that position unique. Primary/primary
1136        // and secondary-only collisions remain ambiguous so fuzzy fallback
1137        // cannot attribute the shared position to an arbitrary record.
1138        let mut ambiguous_aliases = rustc_hash::FxHashSet::default();
1139        let mut ambiguous_anonymous_aliases = rustc_hash::FxHashSet::default();
1140        for function in &mut functions {
1141            let name = function.name.clone();
1142            let is_anonymous = is_anonymous_istanbul_name(&name);
1143            function.aliases.retain(|alias| {
1144                let named = named_alias_counts
1145                    .get(&(name.clone(), alias.position))
1146                    .copied()
1147                    .unwrap_or_default();
1148                let anonymous = is_anonymous
1149                    .then(|| anonymous_alias_counts.get(&alias.position).copied())
1150                    .flatten();
1151                let unique = named.is_unique(*alias)
1152                    && anonymous.is_none_or(|counts| counts.is_unique(*alias));
1153                if unique {
1154                    return true;
1155                }
1156                if alias.primary {
1157                    ambiguous_aliases.insert((
1158                        name.clone(),
1159                        alias.position.line,
1160                        alias.position.col,
1161                    ));
1162                    if anonymous.is_some_and(|counts| counts.primary > 1) {
1163                        ambiguous_anonymous_aliases.insert(alias.position);
1164                    }
1165                } else {
1166                    if named.has_secondary_only_collision() {
1167                        ambiguous_aliases.insert((
1168                            name.clone(),
1169                            alias.position.line,
1170                            alias.position.col,
1171                        ));
1172                    }
1173                    if anonymous.is_some_and(IstanbulAliasCounts::has_secondary_only_collision) {
1174                        ambiguous_anonymous_aliases.insert(alias.position);
1175                    }
1176                }
1177                false
1178            });
1179        }
1180
1181        let mut alias_index = rustc_hash::FxHashMap::default();
1182        for (function_index, function) in functions.iter().enumerate() {
1183            for alias in &function.aliases {
1184                alias_index.insert(
1185                    (
1186                        function.name.clone(),
1187                        alias.position.line,
1188                        alias.position.col,
1189                    ),
1190                    function_index,
1191                );
1192                // An accessor answers to its property name as well, without
1193                // giving up the spelling the producer recorded.
1194                if let Some(property) = accessor_property_name(&function.name) {
1195                    alias_index
1196                        .entry((
1197                            property.to_string(),
1198                            alias.position.line,
1199                            alias.position.col,
1200                        ))
1201                        .or_insert(function_index);
1202                }
1203            }
1204        }
1205
1206        mark_headers_holding_other_fns(&mut functions);
1207        let indexes = IstanbulLineIndexes::build(&functions);
1208
1209        Self {
1210            functions,
1211            alias_index,
1212            ambiguous_aliases,
1213            ambiguous_anonymous_aliases,
1214            alias_lines: indexes.alias_lines,
1215            header_starts: indexes.header_starts,
1216            max_header_height: indexes.max_header_height,
1217            relocated,
1218        }
1219    }
1220
1221    /// Indices of the records with an alias within
1222    /// [`ALIAS_FUZZ_MAX_LINE_DRIFT`] lines of `target`, ascending and
1223    /// deduplicated.
1224    ///
1225    /// This is exactly the candidate set of the fuzzy and anonymous fallbacks:
1226    /// `nearest_alias` yields `None` for a record whose every alias is further
1227    /// away, so a record outside the window cannot produce a distance and
1228    /// cannot win either fallback. Ascending order reproduces the order of a
1229    /// full `self.functions` scan, which both fallbacks' tie-breaks depend on:
1230    /// `min_by_key` keeps the first minimum, and the anonymous scan appends
1231    /// equal-distance records in encounter order.
1232    fn fuzz_window(&self, target: IstanbulPosition) -> Vec<usize> {
1233        let low = target.line.saturating_sub(ALIAS_FUZZ_MAX_LINE_DRIFT);
1234        let high = target.line.saturating_add(ALIAS_FUZZ_MAX_LINE_DRIFT);
1235        let first = self.alias_lines.partition_point(|(line, _)| *line < low);
1236        let past = self.alias_lines.partition_point(|(line, _)| *line <= high);
1237        let mut window: Vec<usize> = self.alias_lines[first..past]
1238            .iter()
1239            .map(|(_, function_index)| *function_index)
1240            .collect();
1241        window.sort_unstable();
1242        window.dedup();
1243        window
1244    }
1245
1246    /// Look up coverage for a function by name, start line, and start column.
1247    ///
1248    /// Resolution order:
1249    /// 1. Exact `(name, line, col)` match.
1250    /// 2. Name-only fuzzy match within ±2 lines (tolerates formatter drift),
1251    ///    tie-broken by smallest `(line, col)` distance from the target.
1252    /// 3. Anonymous fallback: among Istanbul `(anonymous_N)` records with an
1253    ///    alias within ±2 lines, pick the record whose closest alias has the
1254    ///    smallest `(line, col)` distance from the target. A distance tie is
1255    ///    resolved only when one valid body span contains the target and is
1256    ///    strictly inside every other tied span.
1257    /// 4. Unique anonymous header-span fallback between `decl.start` and
1258    ///    `loc.start`.
1259    ///
1260    /// Step 3 covers arrow-function exports where fallow extracts the binding
1261    /// identifier
1262    /// (`const myHandler = () => {...}` yields `myHandler`) while Istanbul
1263    /// records the function as anonymous. `load_istanbul_coverage` indexes
1264    /// declaration aliases so standard Istanbul producers still participate
1265    /// in this fallback. Step 4 covers multi-line callback arguments whose
1266    /// extracted start falls between Istanbul's declaration and body anchors,
1267    /// but only after the established anonymous resolution cannot decide. See
1268    /// issues #155, #166, #181, and #370.
1269    ///
1270    /// When the map is `relocated` (recorded against a different checkout of
1271    /// the project, as in the audit base-worktree pass), a distance-free
1272    /// name match runs between steps 2 and 3: if every entry named `name`
1273    /// carries the same coverage value, that value is returned regardless of
1274    /// line drift. Unrelated edits can shift a function arbitrarily far
1275    /// between the two checkouts, and when all same-named candidates agree
1276    /// the choice among them cannot matter (#2347). Same-checkout lookups
1277    /// keep the bounded fuzz, which protects against stale coverage data.
1278    pub(crate) fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
1279        let exact_key = (name.to_string(), line, col);
1280        if self.ambiguous_aliases.contains(&exact_key) {
1281            return None;
1282        }
1283        if let Some(&function_index) = self.alias_index.get(&exact_key) {
1284            return Some(self.functions[function_index].coverage_pct);
1285        }
1286
1287        let target = IstanbulPosition::new(line, col);
1288        let window = self.fuzz_window(target);
1289        // A function written inside another function's signature is a
1290        // different function from the one the signature belongs to, and both
1291        // sit within a line or two of the same position. Proximity cannot
1292        // tell them apart, so the records whose signature covers this target
1293        // decide which candidates below are eligible at all.
1294        let signature_owners = self.header_spans_containing(target);
1295        if let Some(function) = window
1296            .iter()
1297            .copied()
1298            .filter(|function_index| self.functions[*function_index].answers_to(name))
1299            .filter_map(|function_index| {
1300                let function = &self.functions[function_index];
1301                function
1302                    .nearest_alias(target, None)
1303                    .map(|distance| (distance, function_index))
1304            })
1305            .filter(|(distance, function_index)| {
1306                *distance == (0, 0)
1307                    || !self.foreign_signature_blocks(&signature_owners, *function_index, target)
1308            })
1309            .min_by_key(|(distance, _)| *distance)
1310            .map(|(_, function_index)| &self.functions[function_index])
1311        {
1312            return Some(function.coverage_pct);
1313        }
1314        if self.relocated
1315            && let Some(pct) = self.unambiguous_named_pct(name)
1316        {
1317            return Some(pct);
1318        }
1319        if self.ambiguous_anonymous_aliases.contains(&target) {
1320            return self.unique_anonymous_header_match(&signature_owners);
1321        }
1322
1323        let mut nearest_distance: Option<(u32, u32)> = None;
1324        let mut nearest_functions = Vec::new();
1325        for function_index in window {
1326            let function = &self.functions[function_index];
1327            if !is_anonymous_istanbul_name(&function.name) {
1328                continue;
1329            }
1330            let Some(distance) =
1331                function.nearest_alias(target, Some(ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT))
1332            else {
1333                continue;
1334            };
1335            // Inside a signature, proximity alone cannot tell a member from
1336            // the function written in its parameter list. A candidate that
1337            // neither sits on the target nor contains it is the wrong one.
1338            if distance != (0, 0)
1339                && self.foreign_signature_blocks(&signature_owners, function_index, target)
1340            {
1341                continue;
1342            }
1343            match nearest_distance {
1344                None => {
1345                    nearest_distance = Some(distance);
1346                    nearest_functions.push(function_index);
1347                }
1348                Some(previous) if distance < previous => {
1349                    nearest_distance = Some(distance);
1350                    nearest_functions.clear();
1351                    nearest_functions.push(function_index);
1352                }
1353                Some(previous) if distance == previous => {
1354                    nearest_functions.push(function_index);
1355                }
1356                Some(_) => {}
1357            }
1358        }
1359        let established_match = match nearest_functions.as_slice() {
1360            [] => None,
1361            [function_index] => Some(self.functions[*function_index].coverage_pct),
1362            tied => self.innermost_anonymous_match(tied, target),
1363        };
1364        if established_match.is_some() {
1365            return established_match;
1366        }
1367
1368        self.unique_anonymous_header_match(&signature_owners)
1369    }
1370
1371    /// Indices of the records whose header span contains `target`, ascending.
1372    ///
1373    /// A span reaches `target` from no further than `max_header_height` lines
1374    /// above it, so every containing span starts inside that window.
1375    fn header_spans_containing(&self, target: IstanbulPosition) -> Vec<usize> {
1376        let low = target.line.saturating_sub(self.max_header_height);
1377        let first = self.header_starts.partition_point(|(line, _)| *line < low);
1378        let past = self
1379            .header_starts
1380            .partition_point(|(line, _)| *line <= target.line);
1381        self.header_starts[first..past]
1382            .iter()
1383            .filter(|(_, function_index)| {
1384                self.functions[*function_index]
1385                    .header_span
1386                    .is_some_and(|span| span.contains(target))
1387            })
1388            .map(|(_, function_index)| *function_index)
1389            .collect()
1390    }
1391
1392    /// Whether `candidate` is the wrong record for a target inside a
1393    /// signature it does not own.
1394    ///
1395    /// A default parameter value, a decorator argument, and a class
1396    /// expression in a signature are functions of their own, written a line
1397    /// or two from the member whose signature holds them. Crediting one by
1398    /// proximity reports its coverage for a function that never shared its
1399    /// fate. A candidate that contains the target in its own signature or
1400    /// body is not that case: the target is its code, however the enclosing
1401    /// signature encloses both.
1402    fn foreign_signature_blocks(
1403        &self,
1404        owners: &[usize],
1405        candidate: usize,
1406        target: IstanbulPosition,
1407    ) -> bool {
1408        let function = &self.functions[candidate];
1409        if function
1410            .header_span
1411            .is_some_and(|span| span.contains(target))
1412            || function.body_span.is_some_and(|span| span.contains(target))
1413        {
1414            return false;
1415        }
1416        owners.iter().any(|&owner| {
1417            owner != candidate
1418                && self.functions[owner]
1419                    .header_span
1420                    .is_some_and(|span| span.contains(function.decl_start))
1421        })
1422    }
1423
1424    /// Coverage of the single anonymous record whose signature owns the
1425    /// target, given every record whose header span covers it.
1426    ///
1427    /// A header span runs from `decl.start` to `loc.start`, so it covers the
1428    /// signature: the parameter list, its default values, and any decorators.
1429    /// A function literal written there is a different function from the one
1430    /// that owns the span, and crediting it with the owner's coverage reports
1431    /// never-executed code as covered. Two containing spans therefore abstain,
1432    /// as does a span with a second function declared inside it. Only an
1433    /// anonymous record can win, because a named record is already reachable
1434    /// through the exact and fuzzy name paths.
1435    fn unique_anonymous_header_match(&self, signature_owners: &[usize]) -> Option<f64> {
1436        let [function_index] = signature_owners else {
1437            return None;
1438        };
1439        let function = &self.functions[*function_index];
1440        if !is_anonymous_istanbul_name(&function.name) || function.header_holds_other_fn {
1441            return None;
1442        }
1443        Some(function.coverage_pct)
1444    }
1445
1446    fn innermost_anonymous_match(&self, tied: &[usize], target: IstanbulPosition) -> Option<f64> {
1447        let containing: Option<Vec<_>> = tied
1448            .iter()
1449            .map(|&function_index| {
1450                self.functions[function_index]
1451                    .body_span
1452                    .filter(|span| span.contains(target))
1453                    .map(|span| (function_index, span))
1454            })
1455            .collect();
1456        let containing = containing?;
1457
1458        let mut winner = None;
1459        for &(function_index, candidate_span) in &containing {
1460            let is_strictly_innermost = containing.iter().all(|&(other_index, other_span)| {
1461                other_index == function_index || other_span.strictly_contains(candidate_span)
1462            });
1463            if !is_strictly_innermost {
1464                continue;
1465            }
1466            if winner.replace(function_index).is_some() {
1467                return None;
1468            }
1469        }
1470        winner.map(|function_index| self.functions[function_index].coverage_pct)
1471    }
1472
1473    /// The single coverage value shared by every entry named `name`, or
1474    /// `None` when the name is absent or its records disagree. Bit-exact
1475    /// comparison: distinct functions whose values coincide cannot change the
1476    /// result regardless of which record matched.
1477    fn unambiguous_named_pct(&self, name: &str) -> Option<f64> {
1478        let mut found: Option<f64> = None;
1479        for function in &self.functions {
1480            if !function.answers_to(name) {
1481                continue;
1482            }
1483            match found {
1484                None => found = Some(function.coverage_pct),
1485                Some(previous) if previous.to_bits() == function.coverage_pct.to_bits() => {}
1486                Some(_) => return None,
1487            }
1488        }
1489        found
1490    }
1491}
1492
1493/// Loaded Istanbul coverage data, keyed by canonical file path.
1494pub struct IstanbulCoverage {
1495    files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
1496    format: fallow_output::CoverageInputFormat,
1497}
1498
1499impl IstanbulCoverage {
1500    /// Get coverage data for a file path.
1501    pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
1502        self.files.get(path)
1503    }
1504
1505    /// How many files the coverage map describes, joined or not.
1506    pub fn file_count(&self) -> usize {
1507        self.files.len()
1508    }
1509
1510    /// The input format the coverage was read from.
1511    pub const fn format(&self) -> fallow_output::CoverageInputFormat {
1512        self.format
1513    }
1514}
1515
1516/// Precedence decision for per-function CRAP coverage inputs.
1517///
1518/// Template inheritance wins first so Angular `.html` template findings can
1519/// use the owning `.component.ts` reachability context. Istanbul wins next,
1520/// even when the current file is missing from the coverage map, because that
1521/// path still records unmatched functions in the run-level match counters.
1522/// Plain graph-estimated coverage is the final fallback.
1523enum CrapCoverageResolution<'a> {
1524    TemplateInherited(&'a TemplateInheritContext),
1525    Istanbul {
1526        file_coverage: Option<&'a IstanbulFileCoverage>,
1527    },
1528    StaticEstimated,
1529}
1530
1531fn resolve_crap_coverage<'a>(
1532    template_inherit: Option<&'a TemplateInheritContext>,
1533    istanbul_coverage: Option<&'a IstanbulCoverage>,
1534    path: &std::path::Path,
1535) -> CrapCoverageResolution<'a> {
1536    if let Some(inherit_ctx) = template_inherit {
1537        CrapCoverageResolution::TemplateInherited(inherit_ctx)
1538    } else if let Some(istanbul) = istanbul_coverage {
1539        let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1540        CrapCoverageResolution::Istanbul {
1541            file_coverage: istanbul.get(&canonical),
1542        }
1543    } else {
1544        CrapCoverageResolution::StaticEstimated
1545    }
1546}
1547
1548/// Auto-detect a `coverage-final.json` file in common locations relative to the project root.
1549///
1550/// Checks (in order): `coverage/coverage-final.json`, `.nyc_output/coverage-final.json`.
1551/// Returns the first path found, or `None` if no coverage file exists.
1552/// The audit base-worktree pass uses the same detection against the head
1553/// project root, so auto-detected coverage scores both attribution sides
1554/// (#2347).
1555pub fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
1556    let candidates = [
1557        root.join("coverage/coverage-final.json"),
1558        root.join(".nyc_output/coverage-final.json"),
1559    ];
1560    candidates.into_iter().find(|p| p.is_file())
1561}
1562
1563/// Resolve a relative path against the fallow project root. Returns `path`
1564/// unchanged when it is absolute or `project_root` is `None`. Matches the
1565/// convention every other path-shaped CLI input uses, so a monorepo CI run
1566/// invoked from the workspace root with `--root sub-project` finds
1567/// `sub-project/relative/path.json` instead of `cwd/relative/path.json`.
1568pub fn resolve_relative_to_root(
1569    path: &std::path::Path,
1570    project_root: Option<&std::path::Path>,
1571) -> std::path::PathBuf {
1572    if fallow_types::path_util::is_absolute_path_any_platform(path) {
1573        return path.to_path_buf();
1574    }
1575    match project_root {
1576        Some(root) => root.join(path),
1577        None => path.to_path_buf(),
1578    }
1579}
1580
1581/// If `path` is a directory, looks for `coverage-final.json` inside it.
1582/// Parses the Istanbul JSON format and pre-computes per-function statement
1583/// coverage percentages for efficient lookup during CRAP scoring.
1584///
1585/// When `coverage_root` is provided, file paths in the Istanbul data are rebased:
1586/// the `coverage_root` prefix is stripped and `project_root` is prepended, enabling
1587/// cross-environment matching (e.g., coverage from CI used on a local checkout).
1588///
1589/// `path` itself is resolved against `project_root` when relative, so callers
1590/// can pass `--coverage coverage/foo.json` from a parent directory and have it
1591/// land under the `--root` they configured.
1592///
1593/// `relocated` marks a map recorded against a different checkout of the
1594/// project; see [`IstanbulFileCoverage::lookup`].
1595#[cfg(test)]
1596fn load_istanbul_coverage(
1597    path: &std::path::Path,
1598    coverage_root: Option<&std::path::Path>,
1599    project_root: Option<&std::path::Path>,
1600    relocated: bool,
1601) -> Result<IstanbulCoverage, String> {
1602    load_istanbul_coverage_for_sources(path, coverage_root, project_root, None, relocated)
1603}
1604
1605pub(super) fn load_istanbul_coverage_for_sources(
1606    path: &std::path::Path,
1607    coverage_root: Option<&std::path::Path>,
1608    project_root: Option<&std::path::Path>,
1609    discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1610    relocated: bool,
1611) -> Result<IstanbulCoverage, String> {
1612    super::validate_coverage_root_absolute(coverage_root)?;
1613    let resolved = resolve_relative_to_root(path, project_root);
1614    match read_coverage_input(&resolved)? {
1615        CoverageInput::Istanbul { file_path, json } => {
1616            let raw = parse_coverage_map_tolerantly(&json).map_err(|e| {
1617                format!(
1618                    "failed to parse coverage data from {}: {e}",
1619                    file_path.display()
1620                )
1621            })?;
1622            let sources = CoverageMapSources {
1623                coverage_root,
1624                project_root,
1625                discovered_sources,
1626                relocated,
1627            };
1628            Ok(build_istanbul_coverage(
1629                &raw,
1630                &sources,
1631                fallow_output::CoverageInputFormat::Istanbul,
1632            ))
1633        }
1634        CoverageInput::V8 { dump_files } => {
1635            let scope = super::coverage_v8::V8ScriptScope {
1636                coverage_root,
1637                project_root,
1638                discovered_sources,
1639            };
1640            let raw = super::coverage_v8::load_v8_coverage_map(&dump_files, &scope)?;
1641            // The V8 records already carry rebased canonical paths.
1642            let sources = CoverageMapSources {
1643                coverage_root: None,
1644                project_root,
1645                discovered_sources,
1646                relocated,
1647            };
1648            Ok(build_istanbul_coverage(
1649                &raw,
1650                &sources,
1651                fallow_output::CoverageInputFormat::V8,
1652            ))
1653        }
1654    }
1655}
1656
1657/// A `--coverage` input after format detection.
1658enum CoverageInput {
1659    Istanbul {
1660        file_path: std::path::PathBuf,
1661        json: String,
1662    },
1663    V8 {
1664        dump_files: Vec<std::path::PathBuf>,
1665    },
1666}
1667
1668/// Detect the format of a `--coverage` path. A directory holds either an
1669/// Istanbul `coverage-final.json` (preferred) or `NODE_V8_COVERAGE` dumps; a
1670/// file is an Istanbul map or one V8 dump.
1671fn read_coverage_input(resolved: &std::path::Path) -> Result<CoverageInput, String> {
1672    if resolved.is_dir() {
1673        let candidate = resolved.join("coverage-final.json");
1674        if candidate.is_file() {
1675            return read_coverage_file(candidate);
1676        }
1677        let dump_files = super::coverage_v8::dump_files_in(resolved)?;
1678        if dump_files.is_empty() {
1679            return Err(format!(
1680                "no coverage-final.json or V8 coverage files found in {}",
1681                resolved.display()
1682            ));
1683        }
1684        return Ok(CoverageInput::V8 { dump_files });
1685    }
1686    read_coverage_file(resolved.to_path_buf())
1687}
1688
1689fn read_coverage_file(file_path: std::path::PathBuf) -> Result<CoverageInput, String> {
1690    let json = std::fs::read_to_string(&file_path)
1691        .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
1692    if super::coverage_v8::is_v8_dump(&json) {
1693        return Ok(CoverageInput::V8 {
1694            dump_files: vec![file_path],
1695        });
1696    }
1697    Ok(CoverageInput::Istanbul { file_path, json })
1698}
1699
1700/// How the file paths of a coverage map relate to the analyzed project.
1701#[derive(Clone, Copy)]
1702struct CoverageMapSources<'a> {
1703    coverage_root: Option<&'a std::path::Path>,
1704    project_root: Option<&'a std::path::Path>,
1705    discovered_sources: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1706    relocated: bool,
1707}
1708
1709fn build_istanbul_coverage(
1710    raw: &std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>,
1711    sources: &CoverageMapSources<'_>,
1712    format: fallow_output::CoverageInputFormat,
1713) -> IstanbulCoverage {
1714    let CoverageMapSources {
1715        coverage_root,
1716        project_root,
1717        discovered_sources,
1718        relocated,
1719    } = *sources;
1720    let mut files = rustc_hash::FxHashMap::default();
1721    for file_cov in raw.values() {
1722        // A producer may record project-relative keys. They are relative to
1723        // the project, not to wherever the process happens to run, and
1724        // resolving them against the current directory fails the whole map at
1725        // once with every unit silently falling back to its estimate.
1726        let raw_path = resolve_relative_to_root(std::path::Path::new(&file_cov.path), project_root);
1727        let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
1728            rebase_coverage_path(raw_path, cov_root, proj_root)
1729        } else {
1730            raw_path
1731        };
1732        let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
1733        let source = read_discovered_source(&canonical, discovered_sources, relocated);
1734        let source_index = source
1735            .as_deref()
1736            .map(|source| IstanbulSourceIndex::new(source, &canonical));
1737
1738        let statement_owners = tally_statements_by_owner(file_cov);
1739        let mut functions = Vec::with_capacity(file_cov.fn_map.len());
1740        for (fn_id, fn_entry) in &file_cov.fn_map {
1741            let coverage_pct = compute_function_statement_coverage(
1742                file_cov,
1743                fn_id,
1744                statement_owners.get(fn_id.as_str()),
1745            );
1746            if let Some(function) =
1747                istanbul_function_coverage(fn_entry, coverage_pct, source_index.as_ref())
1748            {
1749                functions.push(function);
1750            }
1751        }
1752
1753        files.insert(canonical, IstanbulFileCoverage::new(functions, relocated));
1754    }
1755
1756    IstanbulCoverage { files, format }
1757}
1758
1759#[expect(
1760    clippy::filetype_is_file,
1761    reason = "coverage provenance must admit regular files and reject every special file type"
1762)]
1763fn read_discovered_source(
1764    path: &std::path::Path,
1765    discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1766    relocated: bool,
1767) -> Option<String> {
1768    if relocated || !discovered_sources.is_some_and(|sources| sources.contains(path)) {
1769        return None;
1770    }
1771    if std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
1772        std::fs::read_to_string(path).ok()
1773    } else {
1774        None
1775    }
1776}
1777
1778/// Parse a coverage map, retrying once with unplaceable coordinates clamped.
1779///
1780/// A producer can emit a negative coordinate for a position it could not
1781/// place: `v8-to-istanbul`, which is what c8 and nyc write, records
1782/// `column: -1` on the implicit else of a bare `if`. Positions are unsigned,
1783/// so one such coordinate rejects the entire map, and it always lands in
1784/// `branchMap`, which nothing here reads. The retry costs a second parse and
1785/// only runs after the strict one has already failed.
1786fn parse_coverage_map_tolerantly(
1787    json: &str,
1788) -> Result<std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>, String> {
1789    match oxc_coverage_instrument::parse_coverage_map(json) {
1790        Ok(raw) => Ok(raw),
1791        Err(strict_error) => {
1792            let mut value: serde_json::Value =
1793                serde_json::from_str(json).map_err(|_| strict_error.to_string())?;
1794            if !clamp_negative_positions(&mut value) {
1795                return Err(strict_error.to_string());
1796            }
1797            serde_json::from_value(value).map_err(|_| strict_error.to_string())
1798        }
1799    }
1800}
1801
1802/// Clamp every negative `line` or `column` in the tree to zero, reporting
1803/// whether anything changed. Zero is what the shared position type already
1804/// uses for a coordinate a producer left null or absent.
1805fn clamp_negative_positions(value: &mut serde_json::Value) -> bool {
1806    match value {
1807        serde_json::Value::Object(entries) => {
1808            let mut clamped = false;
1809            for (key, child) in entries.iter_mut() {
1810                if matches!(key.as_str(), "line" | "column")
1811                    && child.as_i64().is_some_and(|number| number < 0)
1812                {
1813                    *child = serde_json::Value::from(0);
1814                    clamped = true;
1815                    continue;
1816                }
1817                clamped |= clamp_negative_positions(child);
1818            }
1819            clamped
1820        }
1821        serde_json::Value::Array(items) => items.iter_mut().fold(false, |clamped, item| {
1822            clamped | clamp_negative_positions(item)
1823        }),
1824        _ => false,
1825    }
1826}
1827
1828/// The property name behind an accessor record, when a producer prefixed it.
1829///
1830/// istanbul-lib-instrument leaves a class accessor anonymous, but raw V8
1831/// coverage and `oxc-coverage-instrument` record `get area` and `set area`,
1832/// while fallow extracts the unit as `area`. The prefix is the whole
1833/// difference, so a record keeps its own spelling and answers to the bare
1834/// property name as well.
1835fn accessor_property_name(name: &str) -> Option<&str> {
1836    let property = name
1837        .strip_prefix("get ")
1838        .or_else(|| name.strip_prefix("set "))?;
1839    (!property.is_empty() && !property.contains(' ')).then_some(property)
1840}
1841
1842/// Rebase one Istanbul file path from `coverage_root` onto `project_root`.
1843///
1844/// When the recorded path does not start with `coverage_root` verbatim, retry
1845/// with its canonicalized form: coverage generated inside a symlinked
1846/// directory (macOS `/var` vs `/private/var`) records the symlinked spelling
1847/// while the rebase prefix is typically canonical. Paths under neither
1848/// spelling are kept as-is, matching the previous behavior.
1849fn rebase_coverage_path(
1850    raw_path: std::path::PathBuf,
1851    coverage_root: &std::path::Path,
1852    project_root: &std::path::Path,
1853) -> std::path::PathBuf {
1854    if let Ok(rel) = raw_path.strip_prefix(coverage_root) {
1855        return project_root.join(rel);
1856    }
1857    if let Ok(canonical) = dunce::canonicalize(&raw_path)
1858        && let Ok(rel) = canonical.strip_prefix(coverage_root)
1859    {
1860        return project_root.join(rel);
1861    }
1862    raw_path
1863}
1864
1865fn istanbul_function_coverage(
1866    fn_entry: &oxc_coverage_instrument::FnEntry,
1867    coverage_pct: f64,
1868    source_index: Option<&IstanbulSourceIndex<'_>>,
1869) -> Option<IstanbulFunctionCoverage> {
1870    let body_span = IstanbulSpan::from_entry(fn_entry, source_index);
1871    let header_span = IstanbulSpan::header_from_entry(fn_entry, source_index);
1872    let decl_start = normalized_istanbul_position(
1873        fn_entry.decl.start.line,
1874        fn_entry.decl.start.column,
1875        source_index,
1876    )?;
1877    let effective_position = normalized_istanbul_position(
1878        effective_istanbul_fn_line(fn_entry),
1879        fn_entry.decl.start.column,
1880        source_index,
1881    );
1882    let candidates = [
1883        effective_position.map(|position| IstanbulAlias {
1884            position,
1885            primary: true,
1886        }),
1887        Some(IstanbulAlias {
1888            position: decl_start,
1889            primary: true,
1890        }),
1891        body_span.map(|span| IstanbulAlias {
1892            position: span.start,
1893            primary: false,
1894        }),
1895        named_function_syntax_alias(fn_entry, source_index),
1896    ];
1897    let mut aliases: Vec<IstanbulAlias> = Vec::with_capacity(candidates.len());
1898    for candidate in candidates.into_iter().flatten() {
1899        if !aliases
1900            .iter()
1901            .any(|alias| alias.position == candidate.position)
1902        {
1903            aliases.push(candidate);
1904        }
1905    }
1906
1907    Some(IstanbulFunctionCoverage {
1908        name: fn_entry.name.clone(),
1909        coverage_pct,
1910        aliases,
1911        decl_start,
1912        header_holds_other_fn: false,
1913        header_span,
1914        body_span,
1915    })
1916}
1917
1918/// Source index used to reconcile Istanbul's UTF-16 columns with Fallow's
1919/// UTF-8 byte columns and recover exact named-function syntax starts.
1920struct IstanbulSourceIndex<'a> {
1921    source: &'a str,
1922    line_starts: Vec<usize>,
1923    non_ascii_lines: rustc_hash::FxHashMap<usize, Utf16LineIndex>,
1924    named_function_starts: rustc_hash::FxHashMap<usize, usize>,
1925}
1926
1927struct Utf16LineIndex {
1928    utf16_len: u32,
1929    byte_len: usize,
1930    checkpoints: Vec<Utf16Checkpoint>,
1931}
1932
1933struct Utf16Checkpoint {
1934    utf16_start: u32,
1935    utf16_end: u32,
1936    byte_end: usize,
1937}
1938
1939impl<'a> IstanbulSourceIndex<'a> {
1940    fn new(source: &'a str, path: &std::path::Path) -> Self {
1941        let mut line_starts = vec![0];
1942        line_starts.extend(
1943            source
1944                .bytes()
1945                .enumerate()
1946                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
1947        );
1948        let non_ascii_lines = utf16_line_indexes(source, &line_starts);
1949
1950        let named_function_starts = named_function_starts_from_clean_parse(source, path);
1951
1952        Self {
1953            source,
1954            line_starts,
1955            non_ascii_lines,
1956            named_function_starts,
1957        }
1958    }
1959
1960    fn byte_position(&self, line: u32, utf16_column: u32) -> Option<IstanbulPosition> {
1961        let line_index = usize::try_from(line.checked_sub(1)?).ok()?;
1962        let line_start = *self.line_starts.get(line_index)?;
1963        let line_end = self
1964            .line_starts
1965            .get(line_index + 1)
1966            .copied()
1967            .map_or(self.source.len(), |next_start| next_start - 1);
1968        let line_source = self.source.get(line_start..line_end)?;
1969        let byte_column = if let Some(index) = self.non_ascii_lines.get(&line_index) {
1970            index.byte_column(utf16_column)?
1971        } else {
1972            let byte_column = usize::try_from(utf16_column).ok()?;
1973            (byte_column <= line_source.len()).then_some(byte_column)?
1974        };
1975        Some(IstanbulPosition::new(
1976            line,
1977            u32::try_from(byte_column).ok()?,
1978        ))
1979    }
1980
1981    fn absolute_offset(&self, line: u32, utf16_column: u32) -> Option<usize> {
1982        let position = self.byte_position(line, utf16_column)?;
1983        let line_index = usize::try_from(position.line.checked_sub(1)?).ok()?;
1984        self.line_starts
1985            .get(line_index)?
1986            .checked_add(position.col as usize)
1987    }
1988
1989    fn position_at_offset(&self, offset: usize) -> Option<IstanbulPosition> {
1990        if offset > self.source.len() {
1991            return None;
1992        }
1993        let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
1994        Some(IstanbulPosition::new(
1995            u32::try_from(line_index + 1).ok()?,
1996            u32::try_from(offset.checked_sub(self.line_starts[line_index])?).ok()?,
1997        ))
1998    }
1999
2000    fn named_function_start(
2001        &self,
2002        fn_entry: &oxc_coverage_instrument::FnEntry,
2003    ) -> Option<IstanbulPosition> {
2004        let declaration_offset =
2005            self.absolute_offset(fn_entry.decl.start.line, fn_entry.decl.start.column)?;
2006        let syntax_offset = *self.named_function_starts.get(&declaration_offset)?;
2007        self.position_at_offset(syntax_offset)
2008    }
2009}
2010
2011impl Utf16LineIndex {
2012    fn byte_column(&self, utf16_column: u32) -> Option<usize> {
2013        if utf16_column > self.utf16_len {
2014            return None;
2015        }
2016        let completed = self
2017            .checkpoints
2018            .partition_point(|checkpoint| checkpoint.utf16_end <= utf16_column);
2019        if let Some(next) = self.checkpoints.get(completed)
2020            && utf16_column > next.utf16_start
2021        {
2022            return None;
2023        }
2024        let (utf16_base, byte_base) = completed
2025            .checked_sub(1)
2026            .and_then(|index| self.checkpoints.get(index))
2027            .map_or((0, 0), |checkpoint| {
2028                (checkpoint.utf16_end, checkpoint.byte_end)
2029            });
2030        let ascii_width = usize::try_from(utf16_column.checked_sub(utf16_base)?).ok()?;
2031        let byte_column = byte_base.checked_add(ascii_width)?;
2032        (byte_column <= self.byte_len).then_some(byte_column)
2033    }
2034}
2035
2036fn utf16_line_indexes(
2037    source: &str,
2038    line_starts: &[usize],
2039) -> rustc_hash::FxHashMap<usize, Utf16LineIndex> {
2040    let mut indexes = rustc_hash::FxHashMap::default();
2041    for (line_index, &line_start) in line_starts.iter().enumerate() {
2042        let line_end = line_starts
2043            .get(line_index + 1)
2044            .copied()
2045            .map_or(source.len(), |next_start| next_start - 1);
2046        let Some(line) = source.get(line_start..line_end) else {
2047            continue;
2048        };
2049        if line.is_ascii() {
2050            continue;
2051        }
2052        let mut utf16_column = 0_u32;
2053        let mut checkpoints = Vec::new();
2054        for (byte_column, character) in line.char_indices() {
2055            let utf16_width = character.len_utf16() as u32;
2056            if !character.is_ascii() {
2057                checkpoints.push(Utf16Checkpoint {
2058                    utf16_start: utf16_column,
2059                    utf16_end: utf16_column.saturating_add(utf16_width),
2060                    byte_end: byte_column.saturating_add(character.len_utf8()),
2061                });
2062            }
2063            utf16_column = utf16_column.saturating_add(utf16_width);
2064        }
2065        indexes.insert(
2066            line_index,
2067            Utf16LineIndex {
2068                utf16_len: utf16_column,
2069                byte_len: line.len(),
2070                checkpoints,
2071            },
2072        );
2073    }
2074    indexes
2075}
2076
2077fn named_function_starts_from_clean_parse(
2078    source: &str,
2079    path: &std::path::Path,
2080) -> rustc_hash::FxHashMap<usize, usize> {
2081    let source_type = match path.extension().and_then(|extension| extension.to_str()) {
2082        Some("gts") => oxc_span::SourceType::ts(),
2083        Some("gjs") => oxc_span::SourceType::mjs(),
2084        _ => oxc_span::SourceType::from_path(path).unwrap_or_default(),
2085    };
2086    if let Some(starts) = collect_named_function_starts(source, source_type) {
2087        return starts;
2088    }
2089    if source_type.is_jsx() {
2090        return rustc_hash::FxHashMap::default();
2091    }
2092    let jsx_source_type = if source_type.is_typescript() {
2093        oxc_span::SourceType::tsx()
2094    } else {
2095        oxc_span::SourceType::jsx()
2096    };
2097    collect_named_function_starts(source, jsx_source_type).unwrap_or_default()
2098}
2099
2100fn collect_named_function_starts(
2101    source: &str,
2102    source_type: oxc_span::SourceType,
2103) -> Option<rustc_hash::FxHashMap<usize, usize>> {
2104    let allocator = oxc_allocator::Allocator::default();
2105    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
2106    if parsed.fatal_error || !parsed.diagnostics.is_empty() {
2107        return None;
2108    }
2109    let mut starts = rustc_hash::FxHashMap::default();
2110    let mut collector = NamedFunctionSyntaxCollector {
2111        starts: &mut starts,
2112    };
2113    oxc_ast_visit::Visit::visit_program(&mut collector, &parsed.program);
2114    Some(starts)
2115}
2116
2117struct NamedFunctionSyntaxCollector<'a> {
2118    starts: &'a mut rustc_hash::FxHashMap<usize, usize>,
2119}
2120
2121impl<'ast> oxc_ast_visit::Visit<'ast> for NamedFunctionSyntaxCollector<'_> {
2122    fn visit_function(
2123        &mut self,
2124        function: &oxc_ast::ast::Function<'ast>,
2125        flags: oxc_syntax::scope::ScopeFlags,
2126    ) {
2127        if let Some(identifier) = &function.id
2128            && let (Ok(identifier_start), Ok(syntax_start)) = (
2129                usize::try_from(identifier.span.start),
2130                usize::try_from(function.span.start),
2131            )
2132        {
2133            self.starts.insert(identifier_start, syntax_start);
2134        }
2135        oxc_ast_visit::walk::walk_function(self, function, flags);
2136    }
2137}
2138
2139fn normalized_istanbul_position(
2140    line: u32,
2141    column: u32,
2142    source_index: Option<&IstanbulSourceIndex<'_>>,
2143) -> Option<IstanbulPosition> {
2144    match source_index {
2145        Some(index) => index.byte_position(line, column),
2146        None => Some(IstanbulPosition::new(line, column)),
2147    }
2148}
2149
2150/// Exact syntax-backed alias for a named function's source start.
2151///
2152/// Istanbul declares a named function at its identifier, while Fallow records
2153/// the containing `function` or `async` keyword. Parser tokens distinguish
2154/// that real syntax across legal trivia from unrelated same-width text.
2155fn named_function_syntax_alias(
2156    fn_entry: &oxc_coverage_instrument::FnEntry,
2157    source_index: Option<&IstanbulSourceIndex<'_>>,
2158) -> Option<IstanbulAlias> {
2159    if is_anonymous_istanbul_name(&fn_entry.name) {
2160        return None;
2161    }
2162    Some(IstanbulAlias {
2163        position: source_index?.named_function_start(fn_entry)?,
2164        primary: true,
2165    })
2166}
2167
2168fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
2169    if fn_entry.line > 0 {
2170        fn_entry.line
2171    } else {
2172        fn_entry.decl.start.line
2173    }
2174}
2175
2176/// Statements owned by a single `fnMap` record.
2177#[derive(Default)]
2178struct StatementTally {
2179    covered: u32,
2180    total: u32,
2181}
2182
2183/// Whether `outer` encloses `inner`, with both bounds inclusive.
2184///
2185/// These are the bounds the per-function statement scan has always used, so
2186/// which statements a function can claim is unchanged; only how a contested
2187/// statement is awarded moved.
2188fn istanbul_range_contains(
2189    outer: &oxc_coverage_instrument::Location,
2190    inner: &oxc_coverage_instrument::Location,
2191) -> bool {
2192    let after_start = inner.start.line > outer.start.line
2193        || (inner.start.line == outer.start.line && inner.start.column >= outer.start.column);
2194    let before_end = inner.end.line < outer.end.line
2195        || (inner.end.line == outer.end.line && inner.end.column <= outer.end.column);
2196    after_start && before_end
2197}
2198
2199/// Assign every statement to the innermost `fnMap` record that encloses it.
2200///
2201/// A nested function body lies inside the enclosing function's `loc`, so a
2202/// bare containment test charges a closure's statements to the function that
2203/// returns it as well, and an outer function that ran every statement of its
2204/// own is scored down for code it does not own. Ownership here is exclusive:
2205/// a statement counts for exactly one record, or for none at all when no
2206/// record encloses it, which keeps module-scope statements out of every
2207/// function as before.
2208///
2209/// Innermost is the record with the greatest `loc` start. A tie breaks on the
2210/// smallest `loc` end, and a remaining tie between two byte-identical ranges
2211/// keeps the first record in `fnMap` key order, which is stable because the
2212/// map is a `BTreeMap`. Identical ranges are pathological and any winner is
2213/// arbitrary, so determinism is the property that matters.
2214///
2215/// Computed once per file for the same reason as
2216/// [`mark_headers_holding_other_fns`]: every record needs the answer, and the
2217/// alternative rescans the whole statement map once per record.
2218fn tally_statements_by_owner(
2219    file_cov: &oxc_coverage_instrument::FileCoverage,
2220) -> rustc_hash::FxHashMap<&str, StatementTally> {
2221    let mut owners: rustc_hash::FxHashMap<&str, StatementTally> = rustc_hash::FxHashMap::default();
2222
2223    for (stmt_id, stmt_loc) in &file_cov.statement_map {
2224        let mut owner: Option<(&str, &oxc_coverage_instrument::Location)> = None;
2225
2226        for (fn_id, fn_entry) in &file_cov.fn_map {
2227            if !istanbul_range_contains(&fn_entry.loc, stmt_loc) {
2228                continue;
2229            }
2230            let inner_than_current = owner.is_none_or(|(_, best)| {
2231                let candidate = (
2232                    (fn_entry.loc.start.line, fn_entry.loc.start.column),
2233                    std::cmp::Reverse((fn_entry.loc.end.line, fn_entry.loc.end.column)),
2234                );
2235                let incumbent = (
2236                    (best.start.line, best.start.column),
2237                    std::cmp::Reverse((best.end.line, best.end.column)),
2238                );
2239                candidate > incumbent
2240            });
2241            if inner_than_current {
2242                owner = Some((fn_id.as_str(), &fn_entry.loc));
2243            }
2244        }
2245
2246        let Some((fn_id, _)) = owner else {
2247            continue;
2248        };
2249        let tally = owners.entry(fn_id).or_default();
2250        tally.total += 1;
2251        if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
2252            tally.covered += 1;
2253        }
2254    }
2255
2256    owners
2257}
2258
2259/// Compute statement-level coverage percentage for a single function.
2260///
2261/// Takes the fraction of the statements the function owns that have non-zero
2262/// hit counts, where ownership comes from [`tally_statements_by_owner`] and a
2263/// statement belongs only to the innermost function that encloses it. When the
2264/// function owns no statements (a one-liner arrow, a getter, an outer function
2265/// whose whole body is a nested declaration, or a producer that emits an empty
2266/// `statementMap`), falls back to the function hit count as a binary signal.
2267fn compute_function_statement_coverage(
2268    file_cov: &oxc_coverage_instrument::FileCoverage,
2269    fn_id: &str,
2270    owned: Option<&StatementTally>,
2271) -> f64 {
2272    match owned {
2273        Some(tally) if tally.total > 0 => f64::from(tally.covered) / f64::from(tally.total) * 100.0,
2274        _ => {
2275            let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
2276            if hit > 0 { 100.0 } else { 0.0 }
2277        }
2278    }
2279}
2280
2281/// Count unused VALUE exports per file path for O(1) lookup.
2282///
2283/// Type-only exports (interfaces, type aliases) are intentionally excluded ---
2284/// they are a different concern than unused functions/components.
2285fn count_unused_exports_by_path(
2286    unused_exports: &[crate::results::UnusedExportFinding],
2287) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
2288    let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
2289    for exp in unused_exports {
2290        *map.entry(exp.export.path.as_path()).or_default() += 1;
2291    }
2292    map
2293}
2294
2295/// Compute the maintainability index for a single file.
2296///
2297/// Formula:
2298/// ```text
2299/// dampening = min(lines / 50, 1.0)
2300/// fan_out_penalty = min(ln(fan_out + 1) * 4, 15)
2301/// MI = 100 - (complexity_density * 30 * dampening) - (dead_code_ratio * 20) - fan_out_penalty
2302/// ```
2303///
2304/// The dampening factor prevents complexity density from dominating the score
2305/// on small files. A 5-line utility with CC=2 has density 0.40, but is trivially
2306/// readable; without dampening it scores worse than a 192-line function with CC=57
2307/// (density 0.30). Files under 50 lines get proportionally reduced density weight.
2308///
2309/// Fan-out uses logarithmic scaling capped at 15 points to reflect diminishing
2310/// marginal risk (the 30th import is less concerning than the 5th) and prevent
2311/// composition-root files from being unfairly penalized.
2312///
2313/// Clamped to \[0, 100\]. Higher is better.
2314fn compute_maintainability_index(
2315    complexity_density: f64,
2316    dead_code_ratio: f64,
2317    fan_out: usize,
2318    lines: u32,
2319) -> f64 {
2320    let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
2321    let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
2322    #[expect(
2323        clippy::suboptimal_flops,
2324        reason = "formula matches documented specification"
2325    )]
2326    let score = 100.0
2327        - (complexity_density * 30.0 * dampening)
2328        - (dead_code_ratio * 20.0)
2329        - fan_out_penalty;
2330    score.clamp(0.0, 100.0)
2331}
2332
2333fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
2334    (100.0 - score.maintainability_index).clamp(0.0, 100.0)
2335}
2336
2337/// True when the file's CRAP signal is fully covered by configuration: nothing
2338/// meets its effective ceiling while something would have been flagged at the
2339/// canonical 30.0 baseline, or CRAP enforcement is disabled entirely
2340/// (`max_crap_threshold <= 0`). Mirrors the findings pipeline, which emits no
2341/// CRAP finding in exactly these states, so the row must not read `risk`.
2342#[must_use]
2343pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
2344    max_crap_threshold <= 0.0 || (score.crap_above_threshold == 0 && score.crap_exempted > 0)
2345}
2346
2347/// CRAP concern bands generalized over the row's effective ceiling `t`
2348/// (`crap_effective_threshold`, falling back to the run global). At `t = 30`
2349/// the breakpoints are the historical (15, 30, 100). A fully exempt file
2350/// scores `0.0`: band generalization alone cannot drop an exempted file below
2351/// its structural concern, so the tag would keep reading `risk` against a run
2352/// with zero findings.
2353fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2354    if file_score_fully_crap_exempt(score, max_crap_threshold) {
2355        return 0.0;
2356    }
2357    let crap_max = score.crap_max;
2358    let t = score.crap_effective_threshold.unwrap_or(max_crap_threshold);
2359    let half = t / 2.0;
2360    let saturation = t * 10.0 / 3.0;
2361    if crap_max <= 0.0 {
2362        0.0
2363    } else if crap_max < half {
2364        (crap_max / half) * 45.0
2365    } else if crap_max < t {
2366        ((crap_max - half) / half).mul_add(30.0, 45.0)
2367    } else if crap_max < saturation {
2368        ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
2369    } else {
2370        100.0
2371    }
2372}
2373
2374fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2375    file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
2376}
2377
2378/// Which signal places a file at its triage rank: its structural quality (low
2379/// maintainability index) or its untested complexity (CRAP risk). Surfaced per
2380/// row so the human file-scores table can label why a file sits where it does
2381/// when the two axes disagree (e.g. a low-CRAP file outranking a higher-CRAP
2382/// one because its MI is the worse signal).
2383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2384pub enum FileScoreConcern {
2385    /// Ranked by structural quality: a low maintainability index.
2386    Structural,
2387    /// Ranked by untested complexity: a high CRAP score.
2388    Risk,
2389}
2390
2391impl FileScoreConcern {
2392    /// Short lowercase label for the human file-scores table.
2393    pub const fn label(self) -> &'static str {
2394        match self {
2395            Self::Structural => "structure",
2396            Self::Risk => "risk",
2397        }
2398    }
2399}
2400
2401/// Classify which concern drove `score` to its rank. A file with no CRAP
2402/// concern (no CRAP risk at all, or every breaching function exempted by its
2403/// effective ceiling) is always `Structural`; otherwise the larger concern
2404/// wins, with ties (and the boundary where the two are equal) resolving to
2405/// `Risk` because untested complexity is the more urgent signal to act on.
2406///
2407/// `max_crap_threshold` is the run global (`summary.max_crap_threshold`), the
2408/// fallback ceiling for rows without a `crap_effective_threshold` of their own.
2409pub fn file_score_concern_axis(
2410    score: &FileHealthScore,
2411    max_crap_threshold: f64,
2412) -> FileScoreConcern {
2413    let crap_concern = file_score_crap_concern(score, max_crap_threshold);
2414    if crap_concern <= 0.0 {
2415        FileScoreConcern::Structural
2416    } else if crap_concern >= file_score_structural_concern(score) {
2417        FileScoreConcern::Risk
2418    } else {
2419        FileScoreConcern::Structural
2420    }
2421}
2422
2423fn compare_file_score_triage(
2424    a: &FileHealthScore,
2425    b: &FileHealthScore,
2426    max_crap_threshold: f64,
2427) -> std::cmp::Ordering {
2428    file_score_triage_concern(b, max_crap_threshold)
2429        .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
2430        .then_with(|| b.crap_max.total_cmp(&a.crap_max))
2431        .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
2432        .then_with(|| a.path.cmp(&b.path))
2433}
2434
2435/// Inputs for [`compute_file_scores`], bundled so the analysis artifacts stay
2436/// a separate owned argument.
2437#[derive(Clone, Copy)]
2438pub(super) struct FileScoreComputeInput<'a> {
2439    pub(super) modules: &'a [crate::source::ModuleInfo],
2440    pub(super) file_paths:
2441        &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2442    pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2443    pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
2444    pub(super) root: &'a std::path::Path,
2445    pub(super) crap_thresholds: CrapScoreThresholds<'a>,
2446}
2447
2448/// Compute per-file health scores using a pre-computed analysis output.
2449///
2450/// The caller provides an `AnalysisOutput` (with graph and dead code results)
2451/// so this function does not need to re-run the analysis pipeline. Complexity
2452/// density is derived from the already-parsed modules.
2453pub(super) fn compute_file_scores(
2454    input: FileScoreComputeInput<'_>,
2455    analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2456) -> Result<FileScoreOutput, String> {
2457    let FileScoreComputeInput {
2458        modules,
2459        file_paths,
2460        changed_files,
2461        istanbul_coverage,
2462        root,
2463        crap_thresholds,
2464    } = input;
2465    let retained_graph = analysis_output.graph.ok_or("graph not available")?;
2466    let test_coverage = retained_graph.static_test_coverage();
2467    let graph = retained_graph.as_graph();
2468    let results = &analysis_output.results;
2469
2470    let circular_files = collect_circular_files(results);
2471    let top_complex_fns = collect_top_complex_fns(modules, file_paths);
2472    let cycle_members = collect_cycle_members(results);
2473    let direct_callers = collect_direct_callers(graph, file_paths);
2474    let unused_export_names = collect_unused_export_names(results);
2475
2476    let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
2477        .unused_files
2478        .iter()
2479        .map(|f| f.file.path.as_path())
2480        .collect();
2481
2482    let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
2483
2484    let FileScoreCoverageSetup {
2485        module_by_id,
2486        coverage,
2487    } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
2488
2489    let template_inherit =
2490        build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
2491
2492    let mut acc = accumulate_file_scores(
2493        unused_export_names,
2494        &FileScoreLoopCtx {
2495            graph,
2496            test_coverage,
2497            file_paths,
2498            module_by_id: &module_by_id,
2499            unused_files: &unused_files,
2500            unused_exports_by_path: &unused_exports_by_path,
2501            template_inherit: &template_inherit,
2502            istanbul_coverage,
2503            root,
2504            crap_thresholds,
2505        },
2506    );
2507    acc.scores = finalize_file_score_list(
2508        acc.scores,
2509        changed_files,
2510        crap_thresholds.resolver.global.crap,
2511    );
2512
2513    Ok(build_file_score_output(FileScoreOutputParts {
2514        graph,
2515        file_paths,
2516        results,
2517        scores: acc.scores,
2518        coverage,
2519        circular_files,
2520        top_complex_fns,
2521        entry_points: acc.entry_points,
2522        value_export_counts: acc.value_export_counts,
2523        unused_export_names: acc.unused_export_names,
2524        cycle_members,
2525        direct_callers,
2526        istanbul_matched: acc.istanbul_matched,
2527        istanbul_total: acc.istanbul_total,
2528        istanbul_files_joined: acc.istanbul_files_joined,
2529        istanbul_files_total: acc.istanbul_files_total,
2530        coverage_input_format: istanbul_coverage.map(IstanbulCoverage::format),
2531        per_function_crap: acc.per_function_crap,
2532        template_inherit,
2533    }))
2534}
2535
2536/// Read-only inputs threaded into the per-node file-score loop.
2537struct FileScoreLoopCtx<'a> {
2538    graph: &'a fallow_graph::graph::ModuleGraph,
2539    test_coverage: StaticTestCoverage<'a>,
2540    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2541    module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2542    unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
2543    unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
2544    template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2545    istanbul_coverage: Option<&'a IstanbulCoverage>,
2546    /// Project root used to relativize paths into the override resolver's glob
2547    /// space, matching the findings pipeline's `strip_prefix` on the same root.
2548    root: &'a std::path::Path,
2549    crap_thresholds: CrapScoreThresholds<'a>,
2550}
2551
2552/// Mutable accumulators populated by the per-node file-score loop.
2553struct FileScoreAccumulator {
2554    scores: Vec<FileHealthScore>,
2555    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
2556    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
2557    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2558    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2559    istanbul_matched: usize,
2560    istanbul_files_joined: usize,
2561    istanbul_files_total: usize,
2562    istanbul_total: usize,
2563}
2564
2565impl FileScoreAccumulator {
2566    /// Empty accumulator with the score vector pre-sized to the module count.
2567    fn with_capacity(modules: usize) -> Self {
2568        FileScoreAccumulator {
2569            scores: Vec::with_capacity(modules),
2570            entry_points: rustc_hash::FxHashSet::default(),
2571            value_export_counts: rustc_hash::FxHashMap::default(),
2572            unused_export_names: rustc_hash::FxHashMap::default(),
2573            per_function_crap: rustc_hash::FxHashMap::default(),
2574            istanbul_matched: 0,
2575            istanbul_total: 0,
2576            istanbul_files_joined: 0,
2577            istanbul_files_total: 0,
2578        }
2579    }
2580}
2581
2582/// Drive the per-node loop, returning an accumulator with one score per
2583/// analyzable file. `unused_export_names` seeds the accumulator's same field.
2584fn accumulate_file_scores(
2585    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2586    ctx: &FileScoreLoopCtx<'_>,
2587) -> FileScoreAccumulator {
2588    let mut acc = FileScoreAccumulator {
2589        unused_export_names,
2590        istanbul_files_total: ctx
2591            .istanbul_coverage
2592            .map_or(0, IstanbulCoverage::file_count),
2593        ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
2594    };
2595    for node in &ctx.graph.modules {
2596        let Some(path) = ctx.file_paths.get(&node.file_id) else {
2597            continue;
2598        };
2599        record_entry_point(&mut acc.entry_points, node, path);
2600        let score = compute_one_file_score(&mut acc, ctx, node, path);
2601        acc.scores.push(score);
2602    }
2603    acc
2604}
2605
2606/// Apply the changed-file scope filter, drop zero-function barrels, and sort by
2607/// risk-aware triage concern.
2608fn finalize_file_score_list(
2609    mut scores: Vec<FileHealthScore>,
2610    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2611    max_crap_threshold: f64,
2612) -> Vec<FileHealthScore> {
2613    if let Some(changed) = changed_files {
2614        scores.retain(|s| changed.contains(&s.path));
2615    }
2616    scores.retain(|s| s.function_count > 0);
2617    scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
2618    scores
2619}
2620
2621/// Compute the `FileHealthScore` for one node and fold its side data into `acc`.
2622fn compute_one_file_score(
2623    acc: &mut FileScoreAccumulator,
2624    ctx: &FileScoreLoopCtx<'_>,
2625    node: &fallow_graph::graph::ModuleNode,
2626    path: &std::path::Path,
2627) -> FileHealthScore {
2628    let fan_in = ctx
2629        .graph
2630        .reverse_deps
2631        .get(node.file_id.0 as usize)
2632        .map_or(0, Vec::len);
2633    let fan_out = node.edge_range.len();
2634
2635    let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
2636        .module_by_id
2637        .get(&node.file_id)
2638        .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
2639
2640    let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
2641    let path_owned = path.to_path_buf();
2642    acc.value_export_counts
2643        .insert(path_owned.clone(), value_exports);
2644    record_unused_file_export_names(
2645        path_owned.as_path(),
2646        &node.exports,
2647        ctx.unused_files,
2648        &mut acc.unused_export_names,
2649    );
2650
2651    let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
2652        compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
2653
2654    let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
2655    let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative);
2656    let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
2657    acc.istanbul_matched += crap.istanbul_matched;
2658    acc.istanbul_total += crap.istanbul_total;
2659    acc.istanbul_files_joined += usize::from(crap.coverage_file_joined);
2660    record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
2661
2662    // `crap_effective_threshold` is the file's lowest effective ceiling, on the
2663    // wire only when it differs from the run global. Both sides come from the
2664    // same resolved configuration values, so any difference beyond epsilon is a
2665    // real override, never rounding noise.
2666    let global_crap = ctx.crap_thresholds.resolver.global.crap;
2667    let crap_effective_threshold = crap
2668        .signals
2669        .min_ceiling
2670        .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
2671
2672    FileHealthScore {
2673        path: path_owned,
2674        fan_in,
2675        fan_out,
2676        dead_code_ratio: dead_code_ratio_rounded,
2677        complexity_density: complexity_density_rounded,
2678        maintainability_index: maintainability_index_rounded,
2679        total_cyclomatic,
2680        total_cognitive,
2681        function_count,
2682        lines,
2683        crap_max: crap.max,
2684        crap_above_threshold: crap.signals.above,
2685        crap_exempted: crap.signals.exempted,
2686        crap_effective_threshold,
2687    }
2688}
2689
2690/// Compute the rounded dead-code-ratio, complexity-density, and
2691/// maintainability-index metrics for one file.
2692fn compute_file_score_metrics(
2693    node: &fallow_graph::graph::ModuleNode,
2694    path: &std::path::Path,
2695    ctx: &FileScoreLoopCtx<'_>,
2696    total_cyclomatic: u32,
2697    lines: u32,
2698    fan_out: usize,
2699) -> (f64, f64, f64) {
2700    let dead_code_ratio = compute_dead_code_ratio(
2701        path,
2702        &node.exports,
2703        ctx.unused_files,
2704        ctx.unused_exports_by_path,
2705    );
2706    let complexity_density = compute_complexity_density(total_cyclomatic, lines);
2707
2708    let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
2709    let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
2710
2711    let maintainability_index = compute_maintainability_index(
2712        complexity_density_rounded,
2713        dead_code_ratio_rounded,
2714        fan_out,
2715        lines,
2716    );
2717    (
2718        dead_code_ratio_rounded,
2719        complexity_density_rounded,
2720        (maintainability_index * 10.0).round() / 10.0,
2721    )
2722}
2723
2724fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
2725    let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
2726    let unused_deps = parts.results.unused_dependencies.len()
2727        + parts.results.unused_dev_dependencies.len()
2728        + parts.results.unused_optional_dependencies.len();
2729    let analysis_snapshot =
2730        build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
2731    let analysis_counts =
2732        build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
2733    let template_inherit_provenance =
2734        build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
2735
2736    FileScoreOutput {
2737        scores: parts.scores,
2738        coverage: parts.coverage,
2739        circular_files: parts.circular_files,
2740        top_complex_fns: parts.top_complex_fns,
2741        entry_points: parts.entry_points,
2742        value_export_counts: parts.value_export_counts,
2743        unused_export_names: parts.unused_export_names,
2744        cycle_members: parts.cycle_members,
2745        direct_callers: parts.direct_callers,
2746        analysis_counts,
2747        prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
2748        render_fan_in: parts.results.render_fan_in.clone(),
2749        analysis_snapshot,
2750        istanbul_matched: parts.istanbul_matched,
2751        istanbul_total: parts.istanbul_total,
2752        istanbul_files_joined: parts.istanbul_files_joined,
2753        istanbul_files_total: parts.istanbul_files_total,
2754        coverage_input_format: parts.coverage_input_format,
2755        per_function_crap: parts.per_function_crap,
2756        template_inherit_provenance,
2757    }
2758}
2759
2760fn build_file_score_analysis_counts(
2761    results: &crate::results::AnalysisResults,
2762    total_exports: usize,
2763    unused_deps: usize,
2764) -> crate::vital_signs::AnalysisCounts {
2765    crate::vital_signs::AnalysisCounts {
2766        total_exports,
2767        dead_files: results.unused_files.len(),
2768        dead_exports: results.unused_exports.len() + results.unused_types.len(),
2769        unused_deps,
2770        circular_deps: results.circular_dependencies.len(),
2771        total_deps: 0usize,
2772    }
2773}
2774
2775fn build_template_inherit_provenance(
2776    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2777    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2778) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
2779    template_inherit
2780        .into_iter()
2781        .filter_map(|(file_id, ctx)| {
2782            file_paths
2783                .get(&file_id)
2784                .map(|path| ((**path).clone(), ctx.provenance_owner))
2785        })
2786        .collect()
2787}
2788
2789fn record_entry_point(
2790    entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
2791    node: &fallow_graph::graph::ModuleNode,
2792    path: &std::path::Path,
2793) {
2794    if node.is_entry_point() {
2795        entry_points.insert(path.to_path_buf());
2796    }
2797}
2798
2799fn record_unused_file_export_names(
2800    path: &std::path::Path,
2801    exports: &[fallow_graph::graph::ExportSymbol],
2802    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
2803    unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2804) {
2805    if !unused_files.contains(path) || unused_export_names.contains_key(path) {
2806        return;
2807    }
2808
2809    let names: Vec<String> = exports
2810        .iter()
2811        .filter(|export| !export.is_type_only)
2812        .map(|export| export.name.to_string())
2813        .collect();
2814    if !names.is_empty() {
2815        unused_export_names.insert(path.to_path_buf(), names);
2816    }
2817}
2818
2819struct FileScoreCrap {
2820    max: f64,
2821    signals: CrapThresholdSignals,
2822    per_function: Vec<PerFunctionCrap>,
2823    istanbul_matched: usize,
2824    istanbul_total: usize,
2825    /// The coverage map carried an entry for this file. Distinguishes a map
2826    /// that did not join from code the map genuinely says nothing ran in.
2827    coverage_file_joined: bool,
2828}
2829
2830impl FileScoreCrap {
2831    fn empty() -> Self {
2832        Self {
2833            max: 0.0,
2834            signals: CrapThresholdSignals::default(),
2835            per_function: Vec::new(),
2836            istanbul_matched: 0,
2837            istanbul_total: 0,
2838            coverage_file_joined: false,
2839        }
2840    }
2841
2842    fn estimated(result: EstimatedCrapResult) -> Self {
2843        Self {
2844            max: result.max_crap,
2845            signals: result.signals,
2846            per_function: result.per_function,
2847            istanbul_matched: 0,
2848            istanbul_total: 0,
2849            coverage_file_joined: false,
2850        }
2851    }
2852
2853    fn istanbul(result: IstanbulCrapResult, coverage_file_joined: bool) -> Self {
2854        Self {
2855            max: result.max_crap,
2856            signals: result.signals,
2857            per_function: result.per_function,
2858            istanbul_matched: result.matched,
2859            istanbul_total: result.total,
2860            coverage_file_joined,
2861        }
2862    }
2863}
2864
2865fn compute_file_score_crap(
2866    node: &fallow_graph::graph::ModuleNode,
2867    ctx: &FileScoreLoopCtx<'_>,
2868    path: &std::path::Path,
2869    ceilings: &CrapCeilingLookup<'_>,
2870) -> FileScoreCrap {
2871    let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
2872        return FileScoreCrap::empty();
2873    };
2874
2875    let is_coverage_suppressed = crate::suppress::is_file_suppressed(
2876        &module.suppressions,
2877        fallow_types::suppress::IssueKind::CoverageGaps,
2878    );
2879    let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
2880    let resolution = resolve_crap_coverage(
2881        ctx.template_inherit.get(&node.file_id),
2882        ctx.istanbul_coverage,
2883        path,
2884    );
2885    match resolution {
2886        CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
2887            compute_template_inherited_crap(module, inherit_ctx, ceilings)
2888        }
2889        CrapCoverageResolution::Istanbul { file_coverage } => {
2890            compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
2891        }
2892        CrapCoverageResolution::StaticEstimated => compute_static_file_crap(
2893            module,
2894            &node.exports,
2895            ctx.test_coverage,
2896            is_test_reachable,
2897            ceilings,
2898        ),
2899    }
2900}
2901
2902fn compute_template_inherited_crap(
2903    module: &crate::source::ModuleInfo,
2904    inherit_ctx: &TemplateInheritContext,
2905    ceilings: &CrapCeilingLookup<'_>,
2906) -> FileScoreCrap {
2907    FileScoreCrap::estimated(compute_crap_scores_estimated(
2908        &module.complexity,
2909        &inherit_ctx.test_referenced_exports,
2910        inherit_ctx.is_test_reachable,
2911        fallow_output::CoverageSource::EstimatedComponentInherited,
2912        ceilings,
2913    ))
2914}
2915
2916fn compute_istanbul_file_crap(
2917    module: &crate::source::ModuleInfo,
2918    file_coverage: Option<&IstanbulFileCoverage>,
2919    is_test_reachable: bool,
2920    ceilings: &CrapCeilingLookup<'_>,
2921) -> FileScoreCrap {
2922    FileScoreCrap::istanbul(
2923        compute_crap_scores_istanbul(
2924            &module.complexity,
2925            file_coverage,
2926            is_test_reachable,
2927            ceilings,
2928        ),
2929        file_coverage.is_some(),
2930    )
2931}
2932
2933fn compute_static_file_crap(
2934    module: &crate::source::ModuleInfo,
2935    exports: &[fallow_graph::graph::ExportSymbol],
2936    test_coverage: StaticTestCoverage<'_>,
2937    is_test_reachable: bool,
2938    ceilings: &CrapCeilingLookup<'_>,
2939) -> FileScoreCrap {
2940    let test_refs = build_test_referenced_exports(exports, test_coverage);
2941    FileScoreCrap::estimated(compute_crap_scores_estimated(
2942        &module.complexity,
2943        &test_refs,
2944        is_test_reachable,
2945        fallow_output::CoverageSource::Estimated,
2946        ceilings,
2947    ))
2948}
2949
2950fn record_per_function_crap(
2951    per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2952    path: &std::path::Path,
2953    per_function: Vec<PerFunctionCrap>,
2954) {
2955    if !per_function.is_empty() {
2956        per_function_crap.insert(path.to_path_buf(), per_function);
2957    }
2958}
2959
2960struct FileScoreCoverageSetup<'a> {
2961    module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2962    coverage: CoverageGapData,
2963}
2964
2965fn prepare_file_score_coverage_setup<'a>(
2966    modules: &'a [crate::source::ModuleInfo],
2967    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2968    results: &crate::results::AnalysisResults,
2969    graph: &fallow_graph::graph::ModuleGraph,
2970    test_coverage: StaticTestCoverage<'_>,
2971    root: &std::path::Path,
2972) -> FileScoreCoverageSetup<'a> {
2973    let module_by_id: rustc_hash::FxHashMap<_, _> =
2974        modules.iter().map(|m| (m.file_id, m)).collect();
2975    let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
2976        .unused_exports
2977        .iter()
2978        .map(|export| {
2979            (
2980                export.export.path.as_path(),
2981                export.export.export_name.clone(),
2982            )
2983        })
2984        .collect();
2985    let coverage = compute_coverage_gaps(
2986        graph,
2987        test_coverage,
2988        file_paths,
2989        &module_by_id,
2990        &unused_exports,
2991        root,
2992    );
2993    FileScoreCoverageSetup {
2994        module_by_id,
2995        coverage,
2996    }
2997}
2998
2999fn collect_circular_files(
3000    results: &crate::results::AnalysisResults,
3001) -> rustc_hash::FxHashSet<std::path::PathBuf> {
3002    results
3003        .circular_dependencies
3004        .iter()
3005        .flat_map(|c| c.cycle.files.iter().cloned())
3006        .collect()
3007}
3008
3009fn collect_top_complex_fns(
3010    modules: &[crate::source::ModuleInfo],
3011    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3012) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
3013    let mut top_complex_fns = rustc_hash::FxHashMap::default();
3014    for module in modules {
3015        if module.complexity.is_empty() {
3016            continue;
3017        }
3018        let Some(path) = file_paths.get(&module.file_id) else {
3019            continue;
3020        };
3021        // The module-scope unit is excluded: this list feeds the refactoring
3022        // target's evidence copy, which would otherwise render
3023        // "<module> has cognitive complexity 12" next to advice about
3024        // extracting helpers.
3025        let mut funcs: Vec<(String, u32, u16)> = module
3026            .complexity
3027            .iter()
3028            .filter(|f| !fallow_types::extract::is_synthetic_module_unit(&f.name))
3029            .map(|f| (f.name.clone(), f.line, f.cognitive))
3030            .collect();
3031        funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
3032        funcs.truncate(3);
3033        if funcs.first().is_some_and(|worst| worst.2 > 0) {
3034            top_complex_fns.insert((*path).clone(), funcs);
3035        }
3036    }
3037    top_complex_fns
3038}
3039
3040fn collect_cycle_members(
3041    results: &crate::results::AnalysisResults,
3042) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
3043    let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
3044        rustc_hash::FxHashMap::default();
3045    for cycle in &results.circular_dependencies {
3046        for file in &cycle.cycle.files {
3047            let others: Vec<std::path::PathBuf> = cycle
3048                .cycle
3049                .files
3050                .iter()
3051                .filter(|f| *f != file)
3052                .cloned()
3053                .collect();
3054            cycle_members
3055                .entry(file.clone())
3056                .or_default()
3057                .extend(others);
3058        }
3059    }
3060    for members in cycle_members.values_mut() {
3061        members.sort();
3062        members.dedup();
3063    }
3064    cycle_members
3065}
3066
3067fn collect_unused_export_names(
3068    results: &crate::results::AnalysisResults,
3069) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
3070    let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
3071        rustc_hash::FxHashMap::default();
3072    for exp in &results.unused_exports {
3073        unused_export_names
3074            .entry(exp.export.path.clone())
3075            .or_default()
3076            .push(exp.export.export_name.clone());
3077    }
3078    unused_export_names
3079}
3080
3081fn build_analysis_counts_snapshot(
3082    graph: &fallow_graph::graph::ModuleGraph,
3083    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3084    results: &crate::results::AnalysisResults,
3085    unused_deps: usize,
3086) -> AnalysisCountsSnapshot {
3087    let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
3088        graph.modules.len(),
3089        rustc_hash::FxBuildHasher,
3090    );
3091    for module in &graph.modules {
3092        if let Some(path) = file_paths.get(&module.file_id) {
3093            module_export_counts.insert((*path).clone(), module.exports.len());
3094        }
3095    }
3096
3097    let mut unused_export_paths =
3098        Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
3099    unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
3100    unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
3101
3102    let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
3103    unused_dep_package_paths.extend(
3104        results
3105            .unused_dependencies
3106            .iter()
3107            .map(|d| d.dep.path.clone()),
3108    );
3109    unused_dep_package_paths.extend(
3110        results
3111            .unused_dev_dependencies
3112            .iter()
3113            .map(|d| d.dep.path.clone()),
3114    );
3115    unused_dep_package_paths.extend(
3116        results
3117            .unused_optional_dependencies
3118            .iter()
3119            .map(|d| d.dep.path.clone()),
3120    );
3121
3122    AnalysisCountsSnapshot {
3123        unused_file_paths: results
3124            .unused_files
3125            .iter()
3126            .map(|f| f.file.path.clone())
3127            .collect(),
3128        unused_export_paths,
3129        unused_dep_package_paths,
3130        circular_dep_groups: results
3131            .circular_dependencies
3132            .iter()
3133            .map(|c| c.cycle.files.clone())
3134            .collect(),
3135        module_export_counts,
3136    }
3137}
3138
3139#[cfg(test)]
3140mod tests {
3141    use super::super::threshold_overrides::GlobalHealthThresholds;
3142    use super::*;
3143
3144    /// Resolver with no override entries and the given global CRAP ceiling,
3145    /// the default-configuration shape for scoring tests.
3146    fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
3147        ThresholdOverrideResolver::new(
3148            &[],
3149            GlobalHealthThresholds {
3150                cyclomatic: 20,
3151                cognitive: 15,
3152                crap,
3153                unit_size: 120,
3154            },
3155        )
3156    }
3157
3158    /// Resolver with the given override entries over the default global 30.0.
3159    fn test_override_resolver(
3160        overrides: &[fallow_config::HealthThresholdOverride],
3161    ) -> ThresholdOverrideResolver {
3162        ThresholdOverrideResolver::new(
3163            overrides,
3164            GlobalHealthThresholds {
3165                cyclomatic: 20,
3166                cognitive: 15,
3167                crap: CRAP_THRESHOLD,
3168                unit_size: 120,
3169            },
3170        )
3171    }
3172
3173    /// `compute_crap_scores_istanbul` with default-configuration ceilings.
3174    fn istanbul_crap_default(
3175        complexity: &[fallow_types::extract::FunctionComplexity],
3176        file_coverage: Option<&IstanbulFileCoverage>,
3177        is_test_reachable: bool,
3178    ) -> IstanbulCrapResult {
3179        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3180        let ceilings = CrapCeilingLookup::new(
3181            CrapScoreThresholds {
3182                resolver: &resolver,
3183                enforce_crap: true,
3184            },
3185            std::path::Path::new("src/test.ts"),
3186        );
3187        compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
3188    }
3189
3190    /// A coverage map that says nothing about a function is not evidence that
3191    /// the function ran, so passing one must not score it lower than the run
3192    /// without a map would have. Both paths use the same static estimate for a
3193    /// function whose file tests reach.
3194    #[test]
3195    fn an_unmatched_function_scores_the_same_with_and_without_a_coverage_map() {
3196        let temp = tempfile::TempDir::new().unwrap();
3197        let source_path = temp.path().join("src/grade.ts");
3198        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
3199        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
3200
3201        // A map that carries the file but records a function fallow never
3202        // extracted, which is what a stale map or an unresolved producer
3203        // anchor produces.
3204        let coverage_path = temp.path().join("coverage-final.json");
3205        write_single_file_istanbul_fixture(
3206            &coverage_path,
3207            &source_path,
3208            &serde_json::json!({
3209                "0": {
3210                    "name": "unrelated",
3211                    "line": 40,
3212                    "decl": { "start": { "line": 40, "column": 0 }, "end": { "line": 40, "column": 9 } },
3213                    "loc": { "start": { "line": 40, "column": 20 }, "end": { "line": 44, "column": 1 } }
3214                }
3215            }),
3216            &serde_json::json!({ "0": 1 }),
3217        );
3218        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
3219        let canonical_source = dunce::canonicalize(&source_path).unwrap();
3220        let file_coverage = coverage.get(&canonical_source).unwrap();
3221
3222        let function = make_fn_complexity(10);
3223        let with_map =
3224            istanbul_crap_default(std::slice::from_ref(&function), Some(file_coverage), true);
3225        let estimated = compute_crap_scores_estimated(
3226            std::slice::from_ref(&function),
3227            &rustc_hash::FxHashSet::default(),
3228            true,
3229            fallow_output::CoverageSource::Estimated,
3230            &CrapCeilingLookup::new(
3231                CrapScoreThresholds {
3232                    resolver: &test_crap_resolver(CRAP_THRESHOLD),
3233                    enforce_crap: true,
3234                },
3235                std::path::Path::new("src/test.ts"),
3236            ),
3237        );
3238
3239        assert_eq!(with_map.matched, 0);
3240        assert!(
3241            (with_map.per_function[0].crap - estimated.per_function[0].crap).abs() < f64::EPSILON,
3242            "a map that attributes nothing must not change the score"
3243        );
3244        assert_eq!(with_map.per_function[0].coverage_pct, None);
3245    }
3246
3247    fn test_istanbul_file_coverage(
3248        functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
3249        relocated: bool,
3250    ) -> IstanbulFileCoverage {
3251        let functions = functions
3252            .into_iter()
3253            .map(
3254                |((name, line, col), coverage_pct)| IstanbulFunctionCoverage {
3255                    name,
3256                    coverage_pct,
3257                    aliases: vec![primary_alias(line, col)],
3258                    decl_start: IstanbulPosition::new(line, col),
3259                    header_holds_other_fn: false,
3260                    header_span: None,
3261                    body_span: None,
3262                },
3263            )
3264            .collect();
3265        IstanbulFileCoverage::new(functions, relocated)
3266    }
3267
3268    fn primary_alias(line: u32, col: u32) -> IstanbulAlias {
3269        IstanbulAlias {
3270            position: IstanbulPosition::new(line, col),
3271            primary: true,
3272        }
3273    }
3274
3275    fn secondary_alias(line: u32, col: u32) -> IstanbulAlias {
3276        IstanbulAlias {
3277            position: IstanbulPosition::new(line, col),
3278            primary: false,
3279        }
3280    }
3281
3282    fn body_span(start: (u32, u32), end: (u32, u32)) -> IstanbulSpan {
3283        IstanbulSpan {
3284            start: IstanbulPosition::new(start.0, start.1),
3285            end: IstanbulPosition::new(end.0, end.1),
3286        }
3287    }
3288
3289    /// `compute_crap_scores_estimated` with default-configuration ceilings.
3290    fn estimated_crap_default(
3291        complexity: &[fallow_types::extract::FunctionComplexity],
3292        test_referenced_exports: &rustc_hash::FxHashSet<String>,
3293        is_test_reachable: bool,
3294        coverage_source: fallow_output::CoverageSource,
3295    ) -> EstimatedCrapResult {
3296        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3297        let ceilings = CrapCeilingLookup::new(
3298            CrapScoreThresholds {
3299                resolver: &resolver,
3300                enforce_crap: true,
3301            },
3302            std::path::Path::new("src/test.ts"),
3303        );
3304        compute_crap_scores_estimated(
3305            complexity,
3306            test_referenced_exports,
3307            is_test_reachable,
3308            coverage_source,
3309            &ceilings,
3310        )
3311    }
3312
3313    /// `compute_file_scores` with default-configuration CRAP thresholds.
3314    fn compute_file_scores_default(
3315        modules: &[crate::source::ModuleInfo],
3316        file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3317        changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3318        analysis_output: crate::results::DeadCodeAnalysisArtifacts,
3319        istanbul_coverage: Option<&IstanbulCoverage>,
3320        root: &std::path::Path,
3321    ) -> Result<FileScoreOutput, String> {
3322        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3323        compute_file_scores(
3324            FileScoreComputeInput {
3325                modules,
3326                file_paths,
3327                changed_files,
3328                istanbul_coverage,
3329                root,
3330                crap_thresholds: CrapScoreThresholds {
3331                    resolver: &resolver,
3332                    enforce_crap: true,
3333                },
3334            },
3335            analysis_output,
3336        )
3337    }
3338
3339    #[test]
3340    fn maintainability_perfect_score() {
3341        assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
3342    }
3343
3344    #[test]
3345    fn crap_resolution_prefers_template_inheritance_over_istanbul() {
3346        let inherit_ctx = TemplateInheritContext {
3347            is_test_reachable: true,
3348            test_referenced_exports: rustc_hash::FxHashSet::default(),
3349            provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
3350        };
3351        let istanbul = IstanbulCoverage {
3352            files: rustc_hash::FxHashMap::default(),
3353            format: fallow_output::CoverageInputFormat::Istanbul,
3354        };
3355
3356        let resolution = resolve_crap_coverage(
3357            Some(&inherit_ctx),
3358            Some(&istanbul),
3359            std::path::Path::new("/project/src/app.component.html"),
3360        );
3361
3362        assert!(matches!(
3363            resolution,
3364            CrapCoverageResolution::TemplateInherited(_)
3365        ));
3366    }
3367
3368    #[test]
3369    fn crap_resolution_keeps_istanbul_when_file_is_missing() {
3370        let istanbul = IstanbulCoverage {
3371            files: rustc_hash::FxHashMap::default(),
3372            format: fallow_output::CoverageInputFormat::Istanbul,
3373        };
3374
3375        let resolution = resolve_crap_coverage(
3376            None,
3377            Some(&istanbul),
3378            std::path::Path::new("/project/src/missing.ts"),
3379        );
3380
3381        assert!(matches!(
3382            resolution,
3383            CrapCoverageResolution::Istanbul {
3384                file_coverage: None
3385            }
3386        ));
3387    }
3388
3389    #[test]
3390    fn maintainability_clamped_at_zero() {
3391        assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
3392    }
3393
3394    #[test]
3395    fn maintainability_formula_correct() {
3396        let result = compute_maintainability_index(0.5, 0.3, 10, 100);
3397        let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
3398        assert!((result - expected).abs() < 0.01);
3399    }
3400
3401    #[test]
3402    fn maintainability_dead_file_penalty() {
3403        let result = compute_maintainability_index(0.0, 1.0, 0, 100);
3404        assert!((result - 80.0).abs() < f64::EPSILON);
3405    }
3406
3407    #[test]
3408    fn maintainability_fan_out_is_logarithmic() {
3409        let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
3410        let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
3411        let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
3412
3413        assert!(result_10 > 90.0); // ~90.4
3414        assert!(result_100 > 84.0); // 85.0 (capped)
3415        assert!((result_100 - result_200).abs() < f64::EPSILON);
3416    }
3417
3418    #[test]
3419    fn maintainability_fan_out_capped_at_15() {
3420        let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
3421        assert!((result - 65.0).abs() < f64::EPSILON);
3422    }
3423
3424    #[test]
3425    fn maintainability_small_file_dampened() {
3426        let small = compute_maintainability_index(0.40, 0.0, 0, 5);
3427        assert!((small - 98.8).abs() < 0.01);
3428    }
3429
3430    #[test]
3431    fn maintainability_large_file_undampened() {
3432        let large = compute_maintainability_index(0.30, 0.0, 0, 192);
3433        assert!((large - 91.0).abs() < 0.01);
3434    }
3435
3436    #[test]
3437    fn maintainability_small_file_ranks_better_than_complex_large_file() {
3438        let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
3439        let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
3440        assert!(
3441            trivial > nightmare,
3442            "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
3443        );
3444    }
3445
3446    #[test]
3447    fn maintainability_at_dampening_boundary() {
3448        let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
3449        let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
3450        assert!((at_boundary - above_boundary).abs() < 0.01);
3451    }
3452
3453    #[test]
3454    fn maintainability_zero_lines_zero_density_penalty() {
3455        let result = compute_maintainability_index(5.0, 0.0, 0, 0);
3456        assert!((result - 100.0).abs() < f64::EPSILON);
3457    }
3458
3459    #[test]
3460    fn complexity_density_zero_lines() {
3461        assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
3462    }
3463
3464    #[test]
3465    fn complexity_density_normal() {
3466        let result = compute_complexity_density(10, 100);
3467        assert!((result - 0.1).abs() < f64::EPSILON);
3468    }
3469
3470    #[test]
3471    fn complexity_density_high() {
3472        let result = compute_complexity_density(50, 10);
3473        assert!((result - 5.0).abs() < f64::EPSILON);
3474    }
3475
3476    #[test]
3477    fn dead_code_ratio_no_exports() {
3478        let unused_files = rustc_hash::FxHashSet::default();
3479        let unused_map = rustc_hash::FxHashMap::default();
3480        let path = std::path::Path::new("/src/foo.ts");
3481        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3482
3483        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3484        assert!((ratio).abs() < f64::EPSILON);
3485    }
3486
3487    #[test]
3488    fn dead_code_ratio_all_unused_file() {
3489        let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
3490            rustc_hash::FxHashSet::default();
3491        let path = std::path::Path::new("/src/foo.ts");
3492        unused_files.insert(path);
3493        let unused_map = rustc_hash::FxHashMap::default();
3494        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3495
3496        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3497        assert!((ratio - 1.0).abs() < f64::EPSILON);
3498    }
3499
3500    #[test]
3501    fn dead_code_ratio_mix() {
3502        let unused_files = rustc_hash::FxHashSet::default();
3503        let path = std::path::Path::new("/src/foo.ts");
3504
3505        let exports = vec![
3506            fallow_graph::graph::ExportSymbol {
3507                name: crate::source::ExportName::Named("a".into()),
3508                is_type_only: false,
3509                is_side_effect_used: false,
3510                visibility: crate::source::VisibilityTag::None,
3511                expected_unused_reason: None,
3512                span: oxc_span::Span::empty(0),
3513                references: vec![],
3514                reference_paths: Vec::new(),
3515                members: vec![],
3516                deprecated: false,
3517                deprecated_reason: None,
3518            },
3519            fallow_graph::graph::ExportSymbol {
3520                name: crate::source::ExportName::Named("b".into()),
3521                is_type_only: false,
3522                is_side_effect_used: false,
3523                visibility: crate::source::VisibilityTag::None,
3524                expected_unused_reason: None,
3525                span: oxc_span::Span::empty(0),
3526                references: vec![],
3527                reference_paths: Vec::new(),
3528                members: vec![],
3529                deprecated: false,
3530                deprecated_reason: None,
3531            },
3532            fallow_graph::graph::ExportSymbol {
3533                name: crate::source::ExportName::Named("c".into()),
3534                is_type_only: false,
3535                is_side_effect_used: false,
3536                visibility: crate::source::VisibilityTag::None,
3537                expected_unused_reason: None,
3538                span: oxc_span::Span::empty(0),
3539                references: vec![],
3540                reference_paths: Vec::new(),
3541                members: vec![],
3542                deprecated: false,
3543                deprecated_reason: None,
3544            },
3545            fallow_graph::graph::ExportSymbol {
3546                name: crate::source::ExportName::Named("MyType".into()),
3547                is_type_only: true,
3548                is_side_effect_used: false,
3549                visibility: crate::source::VisibilityTag::None,
3550                expected_unused_reason: None,
3551                span: oxc_span::Span::empty(0),
3552                references: vec![],
3553                reference_paths: Vec::new(),
3554                members: vec![],
3555                deprecated: false,
3556                deprecated_reason: None,
3557            },
3558        ];
3559
3560        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3561            rustc_hash::FxHashMap::default();
3562        unused_map.insert(path, 2);
3563
3564        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3565        assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
3566    }
3567
3568    #[test]
3569    fn dead_code_ratio_all_type_only_exports() {
3570        let unused_files = rustc_hash::FxHashSet::default();
3571        let path = std::path::Path::new("/src/types.ts");
3572
3573        let exports = vec![fallow_graph::graph::ExportSymbol {
3574            name: crate::source::ExportName::Named("Foo".into()),
3575            is_type_only: true,
3576            is_side_effect_used: false,
3577            visibility: crate::source::VisibilityTag::None,
3578            expected_unused_reason: None,
3579            span: oxc_span::Span::empty(0),
3580            references: vec![],
3581            reference_paths: Vec::new(),
3582            members: vec![],
3583            deprecated: false,
3584            deprecated_reason: None,
3585        }];
3586        let unused_map = rustc_hash::FxHashMap::default();
3587
3588        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3589        assert!((ratio).abs() < f64::EPSILON);
3590    }
3591
3592    #[test]
3593    fn aggregate_complexity_empty_module() {
3594        let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
3595
3596        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3597        assert_eq!(cyc, 0);
3598        assert_eq!(cog, 0);
3599        assert_eq!(funcs, 0);
3600        assert_eq!(lines, 0);
3601    }
3602
3603    #[test]
3604    fn aggregate_complexity_single_function() {
3605        let module = crate::source::ModuleInfo {
3606            line_offsets: vec![0, 10, 20, 30, 40], // 5 lines
3607            complexity: vec![fallow_types::extract::FunctionComplexity {
3608                name: "doStuff".into(),
3609                is_private_member: false,
3610                line: 1,
3611                col: 0,
3612                cyclomatic: 7,
3613                cognitive: 4,
3614                line_count: 5,
3615                param_count: 0,
3616                react_hook_count: 0,
3617                react_jsx_max_depth: 0,
3618                react_prop_count: 0,
3619                source_hash: None,
3620                contributions: Vec::new(),
3621            }],
3622            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3623        };
3624
3625        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3626        assert_eq!(cyc, 7);
3627        assert_eq!(cog, 4);
3628        assert_eq!(funcs, 1);
3629        assert_eq!(lines, 5);
3630    }
3631
3632    #[test]
3633    fn aggregate_complexity_multiple_functions() {
3634        let module = crate::source::ModuleInfo {
3635            line_offsets: vec![0, 10, 20], // 3 lines
3636            complexity: vec![
3637                fallow_types::extract::FunctionComplexity {
3638                    name: "a".into(),
3639                    is_private_member: false,
3640                    line: 1,
3641                    col: 0,
3642                    cyclomatic: 3,
3643                    cognitive: 2,
3644                    line_count: 1,
3645                    param_count: 0,
3646                    react_hook_count: 0,
3647                    react_jsx_max_depth: 0,
3648                    react_prop_count: 0,
3649                    source_hash: None,
3650                    contributions: Vec::new(),
3651                },
3652                fallow_types::extract::FunctionComplexity {
3653                    name: "b".into(),
3654                    is_private_member: false,
3655                    line: 2,
3656                    col: 0,
3657                    cyclomatic: 5,
3658                    cognitive: 8,
3659                    line_count: 2,
3660                    param_count: 0,
3661                    react_hook_count: 0,
3662                    react_jsx_max_depth: 0,
3663                    react_prop_count: 0,
3664                    source_hash: None,
3665                    contributions: Vec::new(),
3666                },
3667            ],
3668            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3669        };
3670
3671        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3672        assert_eq!(cyc, 8);
3673        assert_eq!(cog, 10);
3674        assert_eq!(funcs, 2);
3675        assert_eq!(lines, 3);
3676    }
3677
3678    #[test]
3679    fn count_unused_exports_empty() {
3680        let exports: Vec<crate::results::UnusedExportFinding> = vec![];
3681        let map = count_unused_exports_by_path(&exports);
3682        assert!(map.is_empty());
3683    }
3684
3685    #[test]
3686    fn count_unused_exports_groups_by_path() {
3687        let exports = vec![
3688            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3689                path: std::path::PathBuf::from("/src/a.ts"),
3690                export_name: "foo".into(),
3691                is_type_only: false,
3692                line: 1,
3693                col: 0,
3694                span_start: 0,
3695                is_re_export: false,
3696                deprecated: false,
3697                deprecated_reason: None,
3698            }),
3699            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3700                path: std::path::PathBuf::from("/src/a.ts"),
3701                export_name: "bar".into(),
3702                is_type_only: false,
3703                line: 5,
3704                col: 0,
3705                span_start: 40,
3706                is_re_export: false,
3707                deprecated: false,
3708                deprecated_reason: None,
3709            }),
3710            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3711                path: std::path::PathBuf::from("/src/b.ts"),
3712                export_name: "baz".into(),
3713                is_type_only: false,
3714                line: 1,
3715                col: 0,
3716                span_start: 0,
3717                is_re_export: false,
3718                deprecated: false,
3719                deprecated_reason: None,
3720            }),
3721        ];
3722        let map = count_unused_exports_by_path(&exports);
3723        assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
3724        assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
3725    }
3726
3727    #[test]
3728    fn dead_code_ratio_all_value_exports_unused() {
3729        let unused_files = rustc_hash::FxHashSet::default();
3730        let path = std::path::Path::new("/src/foo.ts");
3731
3732        let exports = vec![
3733            fallow_graph::graph::ExportSymbol {
3734                name: crate::source::ExportName::Named("a".into()),
3735                is_type_only: false,
3736                is_side_effect_used: false,
3737                visibility: crate::source::VisibilityTag::None,
3738                expected_unused_reason: None,
3739                span: oxc_span::Span::empty(0),
3740                references: vec![],
3741                reference_paths: Vec::new(),
3742                members: vec![],
3743                deprecated: false,
3744                deprecated_reason: None,
3745            },
3746            fallow_graph::graph::ExportSymbol {
3747                name: crate::source::ExportName::Named("b".into()),
3748                is_type_only: false,
3749                is_side_effect_used: false,
3750                visibility: crate::source::VisibilityTag::None,
3751                expected_unused_reason: None,
3752                span: oxc_span::Span::empty(0),
3753                references: vec![],
3754                reference_paths: Vec::new(),
3755                members: vec![],
3756                deprecated: false,
3757                deprecated_reason: None,
3758            },
3759        ];
3760
3761        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3762            rustc_hash::FxHashMap::default();
3763        unused_map.insert(path, 2);
3764
3765        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3766        assert!((ratio - 1.0).abs() < f64::EPSILON);
3767    }
3768
3769    #[test]
3770    fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
3771        let unused_files = rustc_hash::FxHashSet::default();
3772        let path = std::path::Path::new("/src/foo.ts");
3773
3774        let exports = vec![fallow_graph::graph::ExportSymbol {
3775            name: crate::source::ExportName::Named("a".into()),
3776            is_type_only: false,
3777            is_side_effect_used: false,
3778            visibility: crate::source::VisibilityTag::None,
3779            expected_unused_reason: None,
3780            span: oxc_span::Span::empty(0),
3781            references: vec![],
3782            reference_paths: Vec::new(),
3783            members: vec![],
3784            deprecated: false,
3785            deprecated_reason: None,
3786        }];
3787
3788        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3789            rustc_hash::FxHashMap::default();
3790        unused_map.insert(path, 5);
3791
3792        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3793        assert!((ratio - 1.0).abs() < f64::EPSILON);
3794    }
3795
3796    #[test]
3797    fn dead_code_ratio_no_unused_exports_for_path() {
3798        let unused_files = rustc_hash::FxHashSet::default();
3799        let path = std::path::Path::new("/src/clean.ts");
3800
3801        let exports = vec![fallow_graph::graph::ExportSymbol {
3802            name: crate::source::ExportName::Named("used".into()),
3803            is_type_only: false,
3804            is_side_effect_used: false,
3805            visibility: crate::source::VisibilityTag::None,
3806            expected_unused_reason: None,
3807            span: oxc_span::Span::empty(0),
3808            references: vec![],
3809            reference_paths: Vec::new(),
3810            members: vec![],
3811            deprecated: false,
3812            deprecated_reason: None,
3813        }];
3814
3815        let unused_map = rustc_hash::FxHashMap::default();
3816        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3817        assert!(ratio.abs() < f64::EPSILON);
3818    }
3819
3820    #[test]
3821    fn complexity_density_zero_cyclomatic_with_lines() {
3822        let result = compute_complexity_density(0, 100);
3823        assert!(result.abs() < f64::EPSILON);
3824    }
3825
3826    #[test]
3827    fn complexity_density_single_line() {
3828        let result = compute_complexity_density(1, 1);
3829        assert!((result - 1.0).abs() < f64::EPSILON);
3830    }
3831
3832    #[test]
3833    fn maintainability_only_complexity_penalty() {
3834        let result = compute_maintainability_index(3.0, 0.0, 0, 100);
3835        assert!((result - 10.0).abs() < f64::EPSILON);
3836    }
3837
3838    #[test]
3839    fn maintainability_only_dead_code_penalty() {
3840        let result = compute_maintainability_index(0.0, 0.5, 0, 100);
3841        assert!((result - 90.0).abs() < f64::EPSILON);
3842    }
3843
3844    #[test]
3845    fn maintainability_fan_out_one() {
3846        let result = compute_maintainability_index(0.0, 0.0, 1, 100);
3847        let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
3848        assert!((result - expected).abs() < 0.01);
3849    }
3850
3851    #[test]
3852    fn maintainability_all_penalties_maxed() {
3853        let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
3854        assert!(result.abs() < f64::EPSILON);
3855    }
3856
3857    #[test]
3858    fn count_unused_exports_single_file_single_export() {
3859        let exports = vec![crate::results::UnusedExportFinding::with_actions(
3860            crate::results::UnusedExport {
3861                path: std::path::PathBuf::from("/src/only.ts"),
3862                export_name: "lonely".into(),
3863                is_type_only: false,
3864                line: 1,
3865                col: 0,
3866                span_start: 0,
3867                is_re_export: false,
3868                deprecated: false,
3869                deprecated_reason: None,
3870            },
3871        )];
3872        let map = count_unused_exports_by_path(&exports);
3873        assert_eq!(map.len(), 1);
3874        assert_eq!(
3875            map.get(std::path::Path::new("/src/only.ts")).copied(),
3876            Some(1)
3877        );
3878    }
3879
3880    /// Helper to build a minimal `ModuleGraph` from scratch.
3881    fn build_test_graph(
3882        files: &[crate::discover::DiscoveredFile],
3883        entry_point_paths: &[std::path::PathBuf],
3884        resolved_modules: &[fallow_graph::resolve::ResolvedModule],
3885    ) -> fallow_graph::graph::ModuleGraph {
3886        let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
3887            .iter()
3888            .map(|p| crate::discover::EntryPoint {
3889                path: p.clone(),
3890                source: crate::discover::EntryPointSource::PackageJsonMain,
3891            })
3892            .collect();
3893        fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
3894    }
3895
3896    /// Helper to create a `ModuleInfo` with given complexity and line count.
3897    fn make_module_info(
3898        file_id: u32,
3899        line_count: usize,
3900        functions: Vec<fallow_types::extract::FunctionComplexity>,
3901    ) -> crate::source::ModuleInfo {
3902        crate::source::ModuleInfo {
3903            line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
3904            complexity: functions,
3905            ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
3906        }
3907    }
3908
3909    fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
3910        FileHealthScore {
3911            path: std::path::PathBuf::from(path),
3912            fan_in: 0,
3913            fan_out: 0,
3914            dead_code_ratio: 0.0,
3915            complexity_density: 0.0,
3916            maintainability_index,
3917            total_cyclomatic: 0,
3918            total_cognitive: 0,
3919            function_count: 1,
3920            lines: 1,
3921            crap_max,
3922            crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
3923            crap_exempted: 0,
3924            crap_effective_threshold: None,
3925        }
3926    }
3927
3928    fn crap_concern_at_default(crap_max: f64) -> f64 {
3929        file_score_crap_concern(
3930            &make_file_score("/src/concern.ts", 100.0, crap_max),
3931            CRAP_THRESHOLD,
3932        )
3933    }
3934
3935    #[test]
3936    fn file_score_crap_concern_tracks_crap_risk_bands() {
3937        assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
3938        assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
3939        assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3940        assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
3941        assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
3942    }
3943
3944    #[test]
3945    fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
3946        // At t = 500 the band edges scale to (250, 500, 1666.7): a breaching
3947        // 250 sits at the moderate/high edge and a breaching 500 at high.
3948        let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
3949        at_edge.crap_above_threshold = 1;
3950        at_edge.crap_effective_threshold = Some(500.0);
3951        assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
3952
3953        let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
3954        at_ceiling.crap_above_threshold = 1;
3955        at_ceiling.crap_effective_threshold = Some(500.0);
3956        assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3957    }
3958
3959    #[test]
3960    fn file_score_crap_concern_zeroes_fully_exempt_file() {
3961        // The issue's own numbers: crap_max 110 under ceiling 500 with both
3962        // breaches exempted. Band generalization alone would yield 19.8 and
3963        // keep the row above a structural concern of 12; the fully-exempt rule
3964        // must zero it (issue #2228).
3965        let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
3966        exempt.crap_above_threshold = 0;
3967        exempt.crap_exempted = 2;
3968        exempt.crap_effective_threshold = Some(500.0);
3969        assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
3970        assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
3971        assert_eq!(
3972            file_score_concern_axis(&exempt, CRAP_THRESHOLD),
3973            FileScoreConcern::Structural
3974        );
3975    }
3976
3977    #[test]
3978    fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
3979        let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
3980        score.crap_above_threshold = 0;
3981        score.crap_exempted = 2;
3982        assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
3983        assert!(file_score_fully_crap_exempt(&score, 0.0));
3984        assert_eq!(
3985            file_score_concern_axis(&score, 0.0),
3986            FileScoreConcern::Structural
3987        );
3988    }
3989
3990    #[test]
3991    fn file_score_partial_exemption_keeps_risk_axis() {
3992        // One breaching function survives its ceiling: the row is NOT fully
3993        // exempt, so the risk story stays.
3994        let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
3995        mixed.crap_above_threshold = 1;
3996        mixed.crap_exempted = 1;
3997        mixed.crap_effective_threshold = Some(30.0);
3998        assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
3999        assert_eq!(
4000            file_score_concern_axis(&mixed, CRAP_THRESHOLD),
4001            FileScoreConcern::Risk
4002        );
4003    }
4004
4005    #[test]
4006    fn file_score_concern_axis_labels_dominant_signal() {
4007        let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
4008        assert_eq!(
4009            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
4010            FileScoreConcern::Risk
4011        );
4012        assert_eq!(
4013            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
4014            "risk"
4015        );
4016
4017        let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
4018        assert_eq!(
4019            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
4020            FileScoreConcern::Structural
4021        );
4022        assert_eq!(
4023            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
4024            "structure"
4025        );
4026
4027        let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
4028        assert_eq!(
4029            file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
4030            FileScoreConcern::Structural
4031        );
4032    }
4033
4034    #[test]
4035    fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
4036        let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
4037        let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
4038
4039        let mut scores = [low_mi_low_risk, higher_mi_high_risk];
4040        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4041
4042        assert_eq!(
4043            scores[0].path,
4044            std::path::Path::new("/src/higher-mi-high-risk.ts")
4045        );
4046        assert_eq!(
4047            scores[1].path,
4048            std::path::Path::new("/src/low-mi-low-risk.ts")
4049        );
4050    }
4051
4052    #[test]
4053    fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
4054        let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
4055        let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
4056
4057        let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
4058        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4059
4060        assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
4061        assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
4062    }
4063
4064    #[test]
4065    fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
4066        let mut scores = [
4067            make_file_score("/src/b.ts", 70.0, 1.0),
4068            make_file_score("/src/a.ts", 70.0, 1.0),
4069            make_file_score("/src/higher-crap.ts", 70.0, 2.0),
4070            make_file_score("/src/lower-concern.ts", 80.0, 1.0),
4071        ];
4072
4073        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4074
4075        let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
4076        assert_eq!(
4077            paths,
4078            vec![
4079                std::path::Path::new("/src/higher-crap.ts"),
4080                std::path::Path::new("/src/a.ts"),
4081                std::path::Path::new("/src/b.ts"),
4082                std::path::Path::new("/src/lower-concern.ts"),
4083            ]
4084        );
4085    }
4086
4087    #[test]
4088    fn compute_file_scores_empty_graph() {
4089        let files: Vec<crate::discover::DiscoveredFile> = vec![];
4090        let graph = build_test_graph(&files, &[], &[]);
4091        let modules: Vec<crate::source::ModuleInfo> = vec![];
4092        let file_paths = rustc_hash::FxHashMap::default();
4093
4094        let output = crate::results::DeadCodeAnalysisArtifacts {
4095            results: fallow_types::results::AnalysisResults::default(),
4096            timings: None,
4097            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4098            modules: None,
4099            files: None,
4100            script_used_packages: rustc_hash::FxHashSet::default(),
4101            trace_provenance: crate::trace::TraceProvenance::default(),
4102            file_hashes: rustc_hash::FxHashMap::default(),
4103        };
4104
4105        let result = compute_file_scores_default(
4106            &modules,
4107            &file_paths,
4108            None,
4109            output,
4110            None,
4111            std::path::Path::new("/project"),
4112        )
4113        .unwrap();
4114        assert!(result.scores.is_empty());
4115        assert!(result.circular_files.is_empty());
4116        assert!(result.top_complex_fns.is_empty());
4117        assert!(result.entry_points.is_empty());
4118        assert_eq!(result.analysis_counts.total_exports, 0);
4119        assert_eq!(result.analysis_counts.dead_files, 0);
4120    }
4121
4122    #[test]
4123    fn compute_file_scores_no_graph_returns_error() {
4124        let modules: Vec<crate::source::ModuleInfo> = vec![];
4125        let file_paths = rustc_hash::FxHashMap::default();
4126
4127        let output = crate::results::DeadCodeAnalysisArtifacts {
4128            results: fallow_types::results::AnalysisResults::default(),
4129            timings: None,
4130            graph: None,
4131            modules: None,
4132            files: None,
4133            script_used_packages: rustc_hash::FxHashSet::default(),
4134            trace_provenance: crate::trace::TraceProvenance::default(),
4135            file_hashes: rustc_hash::FxHashMap::default(),
4136        };
4137
4138        let result = compute_file_scores_default(
4139            &modules,
4140            &file_paths,
4141            None,
4142            output,
4143            None,
4144            std::path::Path::new("/project"),
4145        );
4146        assert!(result.is_err());
4147        match result {
4148            Err(msg) => assert_eq!(msg, "graph not available"),
4149            Ok(_) => panic!("expected error"),
4150        }
4151    }
4152
4153    #[test]
4154    fn compute_file_scores_single_file_with_function() {
4155        let path_a = std::path::PathBuf::from("/src/a.ts");
4156        let files = vec![crate::discover::DiscoveredFile {
4157            id: crate::discover::FileId(0),
4158            path: path_a.clone(),
4159            size_bytes: 100,
4160        }];
4161
4162        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4163            file_id: crate::discover::FileId(0),
4164            path: path_a.clone(),
4165            exports: vec![fallow_types::extract::ExportInfo {
4166                name: crate::source::ExportName::Named("foo".into()),
4167                local_name: None,
4168                is_type_only: false,
4169                visibility: crate::source::VisibilityTag::None,
4170                expected_unused_reason: None,
4171                span: oxc_span::Span::empty(0),
4172                members: vec![],
4173                is_side_effect_used: false,
4174                super_class: None,
4175                deprecated: false,
4176                deprecated_reason: None,
4177            }]
4178            .into(),
4179            ..Default::default()
4180        }];
4181
4182        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4183
4184        let modules = vec![make_module_info(
4185            0,
4186            10,
4187            vec![fallow_types::extract::FunctionComplexity {
4188                name: "foo".into(),
4189                is_private_member: false,
4190                line: 1,
4191                col: 0,
4192                cyclomatic: 5,
4193                cognitive: 3,
4194                line_count: 10,
4195                param_count: 0,
4196                react_hook_count: 0,
4197                react_jsx_max_depth: 0,
4198                react_prop_count: 0,
4199                source_hash: None,
4200                contributions: Vec::new(),
4201            }],
4202        )];
4203
4204        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4205            rustc_hash::FxHashMap::default();
4206        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4207
4208        let output = crate::results::DeadCodeAnalysisArtifacts {
4209            results: fallow_types::results::AnalysisResults::default(),
4210            timings: None,
4211            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4212            modules: None,
4213            files: None,
4214            script_used_packages: rustc_hash::FxHashSet::default(),
4215            trace_provenance: crate::trace::TraceProvenance::default(),
4216            file_hashes: rustc_hash::FxHashMap::default(),
4217        };
4218
4219        let result = compute_file_scores_default(
4220            &modules,
4221            &file_paths,
4222            None,
4223            output,
4224            None,
4225            std::path::Path::new("/project"),
4226        )
4227        .unwrap();
4228        assert_eq!(result.scores.len(), 1);
4229
4230        let score = &result.scores[0];
4231        assert_eq!(score.path, path_a);
4232        assert_eq!(score.total_cyclomatic, 5);
4233        assert_eq!(score.total_cognitive, 3);
4234        assert_eq!(score.function_count, 1);
4235        assert_eq!(score.lines, 10);
4236        assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
4237        assert!(score.dead_code_ratio.abs() < f64::EPSILON);
4238        assert!(result.entry_points.contains(&path_a));
4239    }
4240
4241    #[test]
4242    fn compute_file_scores_excludes_barrel_files() {
4243        let path_a = std::path::PathBuf::from("/src/index.ts");
4244        let files = vec![crate::discover::DiscoveredFile {
4245            id: crate::discover::FileId(0),
4246            path: path_a.clone(),
4247            size_bytes: 50,
4248        }];
4249
4250        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4251            file_id: crate::discover::FileId(0),
4252            path: path_a.clone(),
4253            ..Default::default()
4254        }];
4255
4256        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4257
4258        let modules = vec![make_module_info(0, 5, vec![])];
4259
4260        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4261            rustc_hash::FxHashMap::default();
4262        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4263
4264        let output = crate::results::DeadCodeAnalysisArtifacts {
4265            results: fallow_types::results::AnalysisResults::default(),
4266            timings: None,
4267            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4268            modules: None,
4269            files: None,
4270            script_used_packages: rustc_hash::FxHashSet::default(),
4271            trace_provenance: crate::trace::TraceProvenance::default(),
4272            file_hashes: rustc_hash::FxHashMap::default(),
4273        };
4274
4275        let result = compute_file_scores_default(
4276            &modules,
4277            &file_paths,
4278            None,
4279            output,
4280            None,
4281            std::path::Path::new("/project"),
4282        )
4283        .unwrap();
4284        assert!(result.scores.is_empty());
4285    }
4286
4287    #[test]
4288    fn compute_file_scores_changed_since_filter() {
4289        let path_a = std::path::PathBuf::from("/src/a.ts");
4290        let path_b = std::path::PathBuf::from("/src/b.ts");
4291        let files = vec![
4292            crate::discover::DiscoveredFile {
4293                id: crate::discover::FileId(0),
4294                path: path_a.clone(),
4295                size_bytes: 100,
4296            },
4297            crate::discover::DiscoveredFile {
4298                id: crate::discover::FileId(1),
4299                path: path_b.clone(),
4300                size_bytes: 100,
4301            },
4302        ];
4303
4304        let resolved_modules = vec![
4305            fallow_graph::resolve::ResolvedModule {
4306                file_id: crate::discover::FileId(0),
4307                path: path_a,
4308                ..Default::default()
4309            },
4310            fallow_graph::resolve::ResolvedModule {
4311                file_id: crate::discover::FileId(1),
4312                path: path_b.clone(),
4313                ..Default::default()
4314            },
4315        ];
4316
4317        let graph = build_test_graph(&files, &[], &resolved_modules);
4318
4319        let modules = vec![
4320            make_module_info(
4321                0,
4322                10,
4323                vec![fallow_types::extract::FunctionComplexity {
4324                    name: "fn_a".into(),
4325                    is_private_member: false,
4326                    line: 1,
4327                    col: 0,
4328                    cyclomatic: 2,
4329                    cognitive: 1,
4330                    line_count: 10,
4331                    param_count: 0,
4332                    react_hook_count: 0,
4333                    react_jsx_max_depth: 0,
4334                    react_prop_count: 0,
4335                    source_hash: None,
4336                    contributions: Vec::new(),
4337                }],
4338            ),
4339            make_module_info(
4340                1,
4341                10,
4342                vec![fallow_types::extract::FunctionComplexity {
4343                    name: "fn_b".into(),
4344                    is_private_member: false,
4345                    line: 1,
4346                    col: 0,
4347                    cyclomatic: 3,
4348                    cognitive: 2,
4349                    line_count: 10,
4350                    param_count: 0,
4351                    react_hook_count: 0,
4352                    react_jsx_max_depth: 0,
4353                    react_prop_count: 0,
4354                    source_hash: None,
4355                    contributions: Vec::new(),
4356                }],
4357            ),
4358        ];
4359
4360        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4361            rustc_hash::FxHashMap::default();
4362        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4363        file_paths.insert(crate::discover::FileId(1), &files[1].path);
4364
4365        let path_b_check = std::path::PathBuf::from("/src/b.ts");
4366        let mut changed = rustc_hash::FxHashSet::default();
4367        changed.insert(path_b);
4368
4369        let output = crate::results::DeadCodeAnalysisArtifacts {
4370            results: fallow_types::results::AnalysisResults::default(),
4371            timings: None,
4372            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4373            modules: None,
4374            files: None,
4375            script_used_packages: rustc_hash::FxHashSet::default(),
4376            trace_provenance: crate::trace::TraceProvenance::default(),
4377            file_hashes: rustc_hash::FxHashMap::default(),
4378        };
4379
4380        let result = compute_file_scores_default(
4381            &modules,
4382            &file_paths,
4383            Some(&changed),
4384            output,
4385            None,
4386            std::path::Path::new("/project"),
4387        )
4388        .unwrap();
4389        assert_eq!(result.scores.len(), 1);
4390        assert_eq!(result.scores[0].path, path_b_check);
4391    }
4392
4393    #[test]
4394    fn compute_file_scores_sorted_by_triage_concern() {
4395        let path_a = std::path::PathBuf::from("/src/a.ts");
4396        let path_b = std::path::PathBuf::from("/src/b.ts");
4397        let files = vec![
4398            crate::discover::DiscoveredFile {
4399                id: crate::discover::FileId(0),
4400                path: path_a.clone(),
4401                size_bytes: 100,
4402            },
4403            crate::discover::DiscoveredFile {
4404                id: crate::discover::FileId(1),
4405                path: path_b.clone(),
4406                size_bytes: 100,
4407            },
4408        ];
4409
4410        let resolved_modules = vec![
4411            fallow_graph::resolve::ResolvedModule {
4412                file_id: crate::discover::FileId(0),
4413                path: path_a.clone(),
4414                ..Default::default()
4415            },
4416            fallow_graph::resolve::ResolvedModule {
4417                file_id: crate::discover::FileId(1),
4418                path: path_b,
4419                ..Default::default()
4420            },
4421        ];
4422
4423        let graph = build_test_graph(&files, &[], &resolved_modules);
4424
4425        let modules = vec![
4426            make_module_info(
4427                0,
4428                10,
4429                vec![fallow_types::extract::FunctionComplexity {
4430                    name: "complex_fn".into(),
4431                    is_private_member: false,
4432                    line: 1,
4433                    col: 0,
4434                    cyclomatic: 30,
4435                    cognitive: 20,
4436                    line_count: 10,
4437                    param_count: 0,
4438                    react_hook_count: 0,
4439                    react_jsx_max_depth: 0,
4440                    react_prop_count: 0,
4441                    source_hash: None,
4442                    contributions: Vec::new(),
4443                }],
4444            ),
4445            make_module_info(
4446                1,
4447                100,
4448                vec![fallow_types::extract::FunctionComplexity {
4449                    name: "simple_fn".into(),
4450                    is_private_member: false,
4451                    line: 1,
4452                    col: 0,
4453                    cyclomatic: 1,
4454                    cognitive: 0,
4455                    line_count: 100,
4456                    param_count: 0,
4457                    react_hook_count: 0,
4458                    react_jsx_max_depth: 0,
4459                    react_prop_count: 0,
4460                    source_hash: None,
4461                    contributions: Vec::new(),
4462                }],
4463            ),
4464        ];
4465
4466        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4467            rustc_hash::FxHashMap::default();
4468        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4469        file_paths.insert(crate::discover::FileId(1), &files[1].path);
4470
4471        let output = crate::results::DeadCodeAnalysisArtifacts {
4472            results: fallow_types::results::AnalysisResults::default(),
4473            timings: None,
4474            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4475            modules: None,
4476            files: None,
4477            script_used_packages: rustc_hash::FxHashSet::default(),
4478            trace_provenance: crate::trace::TraceProvenance::default(),
4479            file_hashes: rustc_hash::FxHashMap::default(),
4480        };
4481
4482        let result = compute_file_scores_default(
4483            &modules,
4484            &file_paths,
4485            None,
4486            output,
4487            None,
4488            std::path::Path::new("/project"),
4489        )
4490        .unwrap();
4491        assert_eq!(result.scores.len(), 2);
4492        assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
4493        assert_eq!(result.scores[0].path, path_a);
4494    }
4495
4496    #[test]
4497    fn compute_file_scores_with_unused_file_populates_evidence() {
4498        let path_a = std::path::PathBuf::from("/src/unused.ts");
4499        let files = vec![crate::discover::DiscoveredFile {
4500            id: crate::discover::FileId(0),
4501            path: path_a.clone(),
4502            size_bytes: 100,
4503        }];
4504
4505        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4506            file_id: crate::discover::FileId(0),
4507            path: path_a.clone(),
4508            exports: vec![fallow_types::extract::ExportInfo {
4509                name: crate::source::ExportName::Named("orphan".into()),
4510                local_name: None,
4511                is_type_only: false,
4512                visibility: crate::source::VisibilityTag::None,
4513                expected_unused_reason: None,
4514                span: oxc_span::Span::empty(0),
4515                members: vec![],
4516                is_side_effect_used: false,
4517                super_class: None,
4518                deprecated: false,
4519                deprecated_reason: None,
4520            }]
4521            .into(),
4522            ..Default::default()
4523        }];
4524
4525        let graph = build_test_graph(&files, &[], &resolved_modules);
4526
4527        let modules = vec![make_module_info(
4528            0,
4529            10,
4530            vec![fallow_types::extract::FunctionComplexity {
4531                name: "orphan".into(),
4532                is_private_member: false,
4533                line: 1,
4534                col: 0,
4535                cyclomatic: 1,
4536                cognitive: 0,
4537                line_count: 10,
4538                param_count: 0,
4539                react_hook_count: 0,
4540                react_jsx_max_depth: 0,
4541                react_prop_count: 0,
4542                source_hash: None,
4543                contributions: Vec::new(),
4544            }],
4545        )];
4546
4547        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4548            rustc_hash::FxHashMap::default();
4549        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4550
4551        let mut results = fallow_types::results::AnalysisResults::default();
4552        results.unused_files.push(
4553            fallow_types::output_dead_code::UnusedFileFinding::with_actions(
4554                fallow_types::results::UnusedFile {
4555                    path: path_a.clone(),
4556                },
4557            ),
4558        );
4559
4560        let output = crate::results::DeadCodeAnalysisArtifacts {
4561            results,
4562            timings: None,
4563            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4564            modules: None,
4565            files: None,
4566            script_used_packages: rustc_hash::FxHashSet::default(),
4567            trace_provenance: crate::trace::TraceProvenance::default(),
4568            file_hashes: rustc_hash::FxHashMap::default(),
4569        };
4570
4571        let result = compute_file_scores_default(
4572            &modules,
4573            &file_paths,
4574            None,
4575            output,
4576            None,
4577            std::path::Path::new("/project"),
4578        )
4579        .unwrap();
4580        assert_eq!(result.scores.len(), 1);
4581        assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
4582        assert!(result.unused_export_names.contains_key(&path_a));
4583        let names = &result.unused_export_names[&path_a];
4584        assert_eq!(names, &["orphan"]);
4585        assert_eq!(result.analysis_counts.dead_files, 1);
4586    }
4587
4588    #[test]
4589    #[expect(
4590        clippy::too_many_lines,
4591        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4592    )]
4593    fn compute_file_scores_tracks_top_complex_functions() {
4594        let path_a = std::path::PathBuf::from("/src/complex.ts");
4595        let files = vec![crate::discover::DiscoveredFile {
4596            id: crate::discover::FileId(0),
4597            path: path_a.clone(),
4598            size_bytes: 500,
4599        }];
4600
4601        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4602            file_id: crate::discover::FileId(0),
4603            path: path_a.clone(),
4604            ..Default::default()
4605        }];
4606
4607        let graph = build_test_graph(&files, &[], &resolved_modules);
4608
4609        let modules = vec![make_module_info(
4610            0,
4611            50,
4612            vec![
4613                fallow_types::extract::FunctionComplexity {
4614                    name: "high".into(),
4615                    is_private_member: false,
4616                    line: 1,
4617                    col: 0,
4618                    cyclomatic: 10,
4619                    cognitive: 20,
4620                    line_count: 10,
4621                    param_count: 0,
4622                    react_hook_count: 0,
4623                    react_jsx_max_depth: 0,
4624                    react_prop_count: 0,
4625                    source_hash: None,
4626                    contributions: Vec::new(),
4627                },
4628                fallow_types::extract::FunctionComplexity {
4629                    name: "medium".into(),
4630                    is_private_member: false,
4631                    line: 11,
4632                    col: 0,
4633                    cyclomatic: 5,
4634                    cognitive: 10,
4635                    line_count: 10,
4636                    param_count: 0,
4637                    react_hook_count: 0,
4638                    react_jsx_max_depth: 0,
4639                    react_prop_count: 0,
4640                    source_hash: None,
4641                    contributions: Vec::new(),
4642                },
4643                fallow_types::extract::FunctionComplexity {
4644                    name: "low".into(),
4645                    is_private_member: false,
4646                    line: 21,
4647                    col: 0,
4648                    cyclomatic: 2,
4649                    cognitive: 5,
4650                    line_count: 10,
4651                    param_count: 0,
4652                    react_hook_count: 0,
4653                    react_jsx_max_depth: 0,
4654                    react_prop_count: 0,
4655                    source_hash: None,
4656                    contributions: Vec::new(),
4657                },
4658                fallow_types::extract::FunctionComplexity {
4659                    name: "trivial".into(),
4660                    is_private_member: false,
4661                    line: 31,
4662                    col: 0,
4663                    cyclomatic: 1,
4664                    cognitive: 1,
4665                    line_count: 10,
4666                    param_count: 0,
4667                    react_hook_count: 0,
4668                    react_jsx_max_depth: 0,
4669                    react_prop_count: 0,
4670                    source_hash: None,
4671                    contributions: Vec::new(),
4672                },
4673            ],
4674        )];
4675
4676        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4677            rustc_hash::FxHashMap::default();
4678        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4679
4680        let output = crate::results::DeadCodeAnalysisArtifacts {
4681            results: fallow_types::results::AnalysisResults::default(),
4682            timings: None,
4683            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4684            modules: None,
4685            files: None,
4686            script_used_packages: rustc_hash::FxHashSet::default(),
4687            trace_provenance: crate::trace::TraceProvenance::default(),
4688            file_hashes: rustc_hash::FxHashMap::default(),
4689        };
4690
4691        let result = compute_file_scores_default(
4692            &modules,
4693            &file_paths,
4694            None,
4695            output,
4696            None,
4697            std::path::Path::new("/project"),
4698        )
4699        .unwrap();
4700        assert!(result.top_complex_fns.contains_key(&path_a));
4701        let top = &result.top_complex_fns[&path_a];
4702        assert_eq!(top.len(), 3);
4703        assert_eq!(top[0].0, "high");
4704        assert_eq!(top[0].2, 20);
4705        assert_eq!(top[1].0, "medium");
4706        assert_eq!(top[1].2, 10);
4707        assert_eq!(top[2].0, "low");
4708        assert_eq!(top[2].2, 5);
4709    }
4710
4711    #[test]
4712    #[expect(
4713        clippy::too_many_lines,
4714        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4715    )]
4716    fn compute_file_scores_with_circular_deps() {
4717        let path_a = std::path::PathBuf::from("/src/a.ts");
4718        let path_b = std::path::PathBuf::from("/src/b.ts");
4719        let files = vec![
4720            crate::discover::DiscoveredFile {
4721                id: crate::discover::FileId(0),
4722                path: path_a.clone(),
4723                size_bytes: 100,
4724            },
4725            crate::discover::DiscoveredFile {
4726                id: crate::discover::FileId(1),
4727                path: path_b.clone(),
4728                size_bytes: 100,
4729            },
4730        ];
4731
4732        let resolved_modules = vec![
4733            fallow_graph::resolve::ResolvedModule {
4734                file_id: crate::discover::FileId(0),
4735                path: path_a.clone(),
4736                ..Default::default()
4737            },
4738            fallow_graph::resolve::ResolvedModule {
4739                file_id: crate::discover::FileId(1),
4740                path: path_b.clone(),
4741                ..Default::default()
4742            },
4743        ];
4744
4745        let graph = build_test_graph(&files, &[], &resolved_modules);
4746
4747        let modules = vec![
4748            make_module_info(
4749                0,
4750                10,
4751                vec![fallow_types::extract::FunctionComplexity {
4752                    name: "fn_a".into(),
4753                    is_private_member: false,
4754                    line: 1,
4755                    col: 0,
4756                    cyclomatic: 2,
4757                    cognitive: 1,
4758                    line_count: 10,
4759                    param_count: 0,
4760                    react_hook_count: 0,
4761                    react_jsx_max_depth: 0,
4762                    react_prop_count: 0,
4763                    source_hash: None,
4764                    contributions: Vec::new(),
4765                }],
4766            ),
4767            make_module_info(
4768                1,
4769                10,
4770                vec![fallow_types::extract::FunctionComplexity {
4771                    name: "fn_b".into(),
4772                    is_private_member: false,
4773                    line: 1,
4774                    col: 0,
4775                    cyclomatic: 3,
4776                    cognitive: 2,
4777                    line_count: 10,
4778                    param_count: 0,
4779                    react_hook_count: 0,
4780                    react_jsx_max_depth: 0,
4781                    react_prop_count: 0,
4782                    source_hash: None,
4783                    contributions: Vec::new(),
4784                }],
4785            ),
4786        ];
4787
4788        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4789            rustc_hash::FxHashMap::default();
4790        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4791        file_paths.insert(crate::discover::FileId(1), &files[1].path);
4792
4793        let mut results = fallow_types::results::AnalysisResults::default();
4794        results.circular_dependencies.push(
4795            fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
4796                fallow_types::results::CircularDependency {
4797                    files: vec![path_a.clone(), path_b.clone()],
4798                    length: 2,
4799                    line: 1,
4800                    col: 0,
4801                    edges: Vec::new(),
4802                    is_cross_package: false,
4803                },
4804            ),
4805        );
4806
4807        let output = crate::results::DeadCodeAnalysisArtifacts {
4808            results,
4809            timings: None,
4810            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4811            modules: None,
4812            files: None,
4813            script_used_packages: rustc_hash::FxHashSet::default(),
4814            trace_provenance: crate::trace::TraceProvenance::default(),
4815            file_hashes: rustc_hash::FxHashMap::default(),
4816        };
4817
4818        let result = compute_file_scores_default(
4819            &modules,
4820            &file_paths,
4821            None,
4822            output,
4823            None,
4824            std::path::Path::new("/project"),
4825        )
4826        .unwrap();
4827        assert!(result.circular_files.contains(&path_a));
4828        assert!(result.circular_files.contains(&path_b));
4829        assert!(result.cycle_members.contains_key(&path_a));
4830        assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
4831        assert!(result.cycle_members.contains_key(&path_b));
4832        assert_eq!(result.cycle_members[&path_b], vec![path_a]);
4833        assert_eq!(result.analysis_counts.circular_deps, 1);
4834    }
4835
4836    #[test]
4837    #[expect(
4838        clippy::too_many_lines,
4839        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4840    )]
4841    fn compute_file_scores_analysis_counts_unused_exports_and_types() {
4842        let path_a = std::path::PathBuf::from("/src/a.ts");
4843        let files = vec![crate::discover::DiscoveredFile {
4844            id: crate::discover::FileId(0),
4845            path: path_a.clone(),
4846            size_bytes: 100,
4847        }];
4848
4849        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4850            file_id: crate::discover::FileId(0),
4851            path: path_a.clone(),
4852            exports: vec![
4853                fallow_types::extract::ExportInfo {
4854                    name: crate::source::ExportName::Named("foo".into()),
4855                    local_name: None,
4856                    is_type_only: false,
4857                    visibility: crate::source::VisibilityTag::None,
4858                    expected_unused_reason: None,
4859                    span: oxc_span::Span::empty(0),
4860                    members: vec![],
4861                    is_side_effect_used: false,
4862                    super_class: None,
4863                    deprecated: false,
4864                    deprecated_reason: None,
4865                },
4866                fallow_types::extract::ExportInfo {
4867                    name: crate::source::ExportName::Named("bar".into()),
4868                    local_name: None,
4869                    is_type_only: false,
4870                    visibility: crate::source::VisibilityTag::None,
4871                    expected_unused_reason: None,
4872                    span: oxc_span::Span::empty(0),
4873                    members: vec![],
4874                    is_side_effect_used: false,
4875                    super_class: None,
4876                    deprecated: false,
4877                    deprecated_reason: None,
4878                },
4879            ]
4880            .into(),
4881            ..Default::default()
4882        }];
4883
4884        let graph = build_test_graph(&files, &[], &resolved_modules);
4885
4886        let mut module = make_module_info(
4887            0,
4888            10,
4889            vec![fallow_types::extract::FunctionComplexity {
4890                name: "fn_a".into(),
4891                is_private_member: false,
4892                line: 1,
4893                col: 0,
4894                cyclomatic: 1,
4895                cognitive: 0,
4896                line_count: 10,
4897                param_count: 0,
4898                react_hook_count: 0,
4899                react_jsx_max_depth: 0,
4900                react_prop_count: 0,
4901                source_hash: None,
4902                contributions: Vec::new(),
4903            }],
4904        );
4905        module.exports = vec![
4906            fallow_types::extract::ExportInfo {
4907                name: crate::source::ExportName::Named("foo".into()),
4908                local_name: None,
4909                is_type_only: false,
4910                visibility: crate::source::VisibilityTag::None,
4911                expected_unused_reason: None,
4912                span: oxc_span::Span::empty(0),
4913                members: vec![],
4914                is_side_effect_used: false,
4915                super_class: None,
4916                deprecated: false,
4917                deprecated_reason: None,
4918            },
4919            fallow_types::extract::ExportInfo {
4920                name: crate::source::ExportName::Named("bar".into()),
4921                local_name: None,
4922                is_type_only: false,
4923                visibility: crate::source::VisibilityTag::None,
4924                expected_unused_reason: None,
4925                span: oxc_span::Span::empty(0),
4926                members: vec![],
4927                is_side_effect_used: false,
4928                super_class: None,
4929                deprecated: false,
4930                deprecated_reason: None,
4931            },
4932        ]
4933        .into();
4934        let modules = vec![module];
4935
4936        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4937            rustc_hash::FxHashMap::default();
4938        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4939
4940        let mut results = fallow_types::results::AnalysisResults::default();
4941        results.unused_exports.push(
4942            fallow_types::output_dead_code::UnusedExportFinding::with_actions(
4943                fallow_types::results::UnusedExport {
4944                    path: path_a.clone(),
4945                    export_name: "foo".into(),
4946                    is_type_only: false,
4947                    line: 1,
4948                    col: 0,
4949                    span_start: 0,
4950                    is_re_export: false,
4951                    deprecated: false,
4952                    deprecated_reason: None,
4953                },
4954            ),
4955        );
4956        results.unused_types.push(
4957            fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
4958                fallow_types::results::UnusedExport {
4959                    path: path_a,
4960                    export_name: "MyType".into(),
4961                    is_type_only: true,
4962                    line: 5,
4963                    col: 0,
4964                    span_start: 40,
4965                    is_re_export: false,
4966                    deprecated: false,
4967                    deprecated_reason: None,
4968                },
4969            ),
4970        );
4971        results.unused_dependencies.push(
4972            fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
4973                fallow_types::results::UnusedDependency {
4974                    package_name: "lodash".into(),
4975                    location: fallow_types::results::DependencyLocation::Dependencies,
4976                    path: std::path::PathBuf::from("/package.json"),
4977                    line: 1,
4978                    used_in_workspaces: Vec::new(),
4979                    declared_and_imported_in: Vec::new(),
4980                },
4981            ),
4982        );
4983
4984        let output = crate::results::DeadCodeAnalysisArtifacts {
4985            results,
4986            timings: None,
4987            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4988            modules: None,
4989            files: None,
4990            script_used_packages: rustc_hash::FxHashSet::default(),
4991            trace_provenance: crate::trace::TraceProvenance::default(),
4992            file_hashes: rustc_hash::FxHashMap::default(),
4993        };
4994
4995        let result = compute_file_scores_default(
4996            &modules,
4997            &file_paths,
4998            None,
4999            output,
5000            None,
5001            std::path::Path::new("/project"),
5002        )
5003        .unwrap();
5004        assert_eq!(result.analysis_counts.total_exports, 2);
5005        assert_eq!(result.analysis_counts.dead_exports, 2);
5006        assert_eq!(result.analysis_counts.unused_deps, 1);
5007    }
5008
5009    /// Regression: total_exports must count graph modules, not extraction modules.
5010    #[test]
5011    #[expect(
5012        clippy::too_many_lines,
5013        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5014    )]
5015    fn total_exports_counts_graph_modules_not_extraction_modules() {
5016        let path_a = std::path::PathBuf::from("/src/a.ts");
5017        let files = vec![crate::discover::DiscoveredFile {
5018            id: crate::discover::FileId(0),
5019            path: path_a.clone(),
5020            size_bytes: 100,
5021        }];
5022
5023        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5024            file_id: crate::discover::FileId(0),
5025            path: path_a.clone(),
5026            exports: vec![
5027                fallow_types::extract::ExportInfo {
5028                    name: crate::source::ExportName::Named("foo".into()),
5029                    local_name: None,
5030                    is_type_only: false,
5031                    visibility: crate::source::VisibilityTag::None,
5032                    expected_unused_reason: None,
5033                    span: oxc_span::Span::empty(0),
5034                    members: vec![],
5035                    is_side_effect_used: false,
5036                    super_class: None,
5037                    deprecated: false,
5038                    deprecated_reason: None,
5039                },
5040                fallow_types::extract::ExportInfo {
5041                    name: crate::source::ExportName::Named("bar".into()),
5042                    local_name: None,
5043                    is_type_only: false,
5044                    visibility: crate::source::VisibilityTag::None,
5045                    expected_unused_reason: None,
5046                    span: oxc_span::Span::empty(0),
5047                    members: vec![],
5048                    is_side_effect_used: false,
5049                    super_class: None,
5050                    deprecated: false,
5051                    deprecated_reason: None,
5052                },
5053                fallow_types::extract::ExportInfo {
5054                    name: crate::source::ExportName::Named("baz".into()),
5055                    local_name: None,
5056                    is_type_only: false,
5057                    visibility: crate::source::VisibilityTag::None,
5058                    expected_unused_reason: None,
5059                    span: oxc_span::Span::new(0, 0),
5060                    members: vec![],
5061                    is_side_effect_used: false,
5062                    super_class: None,
5063                    deprecated: false,
5064                    deprecated_reason: None,
5065                },
5066            ]
5067            .into(),
5068            ..Default::default()
5069        }];
5070
5071        let graph = build_test_graph(&files, &[], &resolved_modules);
5072
5073        let mut module = make_module_info(
5074            0,
5075            10,
5076            vec![fallow_types::extract::FunctionComplexity {
5077                name: "fn_a".into(),
5078                is_private_member: false,
5079                line: 1,
5080                col: 0,
5081                cyclomatic: 1,
5082                cognitive: 0,
5083                line_count: 10,
5084                param_count: 0,
5085                react_hook_count: 0,
5086                react_jsx_max_depth: 0,
5087                react_prop_count: 0,
5088                source_hash: None,
5089                contributions: Vec::new(),
5090            }],
5091        );
5092        module.exports = vec![
5093            fallow_types::extract::ExportInfo {
5094                name: crate::source::ExportName::Named("foo".into()),
5095                local_name: None,
5096                is_type_only: false,
5097                visibility: crate::source::VisibilityTag::None,
5098                expected_unused_reason: None,
5099                span: oxc_span::Span::empty(0),
5100                members: vec![],
5101                is_side_effect_used: false,
5102                super_class: None,
5103                deprecated: false,
5104                deprecated_reason: None,
5105            },
5106            fallow_types::extract::ExportInfo {
5107                name: crate::source::ExportName::Named("bar".into()),
5108                local_name: None,
5109                is_type_only: false,
5110                visibility: crate::source::VisibilityTag::None,
5111                expected_unused_reason: None,
5112                span: oxc_span::Span::empty(0),
5113                members: vec![],
5114                is_side_effect_used: false,
5115                super_class: None,
5116                deprecated: false,
5117                deprecated_reason: None,
5118            },
5119        ]
5120        .into();
5121        let modules = vec![module];
5122
5123        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5124            rustc_hash::FxHashMap::default();
5125        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5126
5127        let mut results = fallow_types::results::AnalysisResults::default();
5128        for name in ["foo", "bar", "baz"] {
5129            results.unused_exports.push(
5130                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5131                    fallow_types::results::UnusedExport {
5132                        path: path_a.clone(),
5133                        export_name: name.into(),
5134                        is_type_only: false,
5135                        line: 1,
5136                        col: 0,
5137                        span_start: 0,
5138                        is_re_export: name == "baz",
5139                        deprecated: false,
5140                        deprecated_reason: None,
5141                    },
5142                ),
5143            );
5144        }
5145
5146        let output = crate::results::DeadCodeAnalysisArtifacts {
5147            results,
5148            timings: None,
5149            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5150            modules: None,
5151            files: None,
5152            script_used_packages: rustc_hash::FxHashSet::default(),
5153            trace_provenance: crate::trace::TraceProvenance::default(),
5154            file_hashes: rustc_hash::FxHashMap::default(),
5155        };
5156
5157        let result = compute_file_scores_default(
5158            &modules,
5159            &file_paths,
5160            None,
5161            output,
5162            None,
5163            std::path::Path::new("/project"),
5164        )
5165        .unwrap();
5166        assert_eq!(result.analysis_counts.total_exports, 3);
5167        assert_eq!(result.analysis_counts.dead_exports, 3);
5168    }
5169
5170    #[test]
5171    fn compute_file_scores_module_not_in_file_paths_skipped() {
5172        let path_a = std::path::PathBuf::from("/src/a.ts");
5173        let files = vec![crate::discover::DiscoveredFile {
5174            id: crate::discover::FileId(0),
5175            path: path_a.clone(),
5176            size_bytes: 100,
5177        }];
5178
5179        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5180            file_id: crate::discover::FileId(0),
5181            path: path_a,
5182            ..Default::default()
5183        }];
5184
5185        let graph = build_test_graph(&files, &[], &resolved_modules);
5186
5187        let modules = vec![make_module_info(
5188            0,
5189            10,
5190            vec![fallow_types::extract::FunctionComplexity {
5191                name: "fn_a".into(),
5192                is_private_member: false,
5193                line: 1,
5194                col: 0,
5195                cyclomatic: 2,
5196                cognitive: 1,
5197                line_count: 10,
5198                param_count: 0,
5199                react_hook_count: 0,
5200                react_jsx_max_depth: 0,
5201                react_prop_count: 0,
5202                source_hash: None,
5203                contributions: Vec::new(),
5204            }],
5205        )];
5206
5207        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5208            rustc_hash::FxHashMap::default();
5209
5210        let output = crate::results::DeadCodeAnalysisArtifacts {
5211            results: fallow_types::results::AnalysisResults::default(),
5212            timings: None,
5213            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5214            modules: None,
5215            files: None,
5216            script_used_packages: rustc_hash::FxHashSet::default(),
5217            trace_provenance: crate::trace::TraceProvenance::default(),
5218            file_hashes: rustc_hash::FxHashMap::default(),
5219        };
5220
5221        let result = compute_file_scores_default(
5222            &modules,
5223            &file_paths,
5224            None,
5225            output,
5226            None,
5227            std::path::Path::new("/project"),
5228        )
5229        .unwrap();
5230        assert!(result.scores.is_empty());
5231    }
5232
5233    #[test]
5234    fn compute_file_scores_mi_rounded_to_one_decimal() {
5235        let path_a = std::path::PathBuf::from("/src/a.ts");
5236        let files = vec![crate::discover::DiscoveredFile {
5237            id: crate::discover::FileId(0),
5238            path: path_a.clone(),
5239            size_bytes: 100,
5240        }];
5241
5242        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5243            file_id: crate::discover::FileId(0),
5244            path: path_a.clone(),
5245            ..Default::default()
5246        }];
5247
5248        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5249
5250        let modules = vec![make_module_info(
5251            0,
5252            100,
5253            vec![fallow_types::extract::FunctionComplexity {
5254                name: "fn".into(),
5255                is_private_member: false,
5256                line: 1,
5257                col: 0,
5258                cyclomatic: 7,
5259                cognitive: 3,
5260                line_count: 100,
5261                param_count: 0,
5262                react_hook_count: 0,
5263                react_jsx_max_depth: 0,
5264                react_prop_count: 0,
5265                source_hash: None,
5266                contributions: Vec::new(),
5267            }],
5268        )];
5269
5270        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5271            rustc_hash::FxHashMap::default();
5272        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5273
5274        let output = crate::results::DeadCodeAnalysisArtifacts {
5275            results: fallow_types::results::AnalysisResults::default(),
5276            timings: None,
5277            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5278            modules: None,
5279            files: None,
5280            script_used_packages: rustc_hash::FxHashSet::default(),
5281            trace_provenance: crate::trace::TraceProvenance::default(),
5282            file_hashes: rustc_hash::FxHashMap::default(),
5283        };
5284
5285        let result = compute_file_scores_default(
5286            &modules,
5287            &file_paths,
5288            None,
5289            output,
5290            None,
5291            std::path::Path::new("/project"),
5292        )
5293        .unwrap();
5294        let mi = result.scores[0].maintainability_index;
5295        let rounded = (mi * 10.0).round() / 10.0;
5296        assert!((mi - rounded).abs() < f64::EPSILON);
5297    }
5298
5299    #[test]
5300    fn compute_file_scores_value_export_counts_tracked() {
5301        let path_a = std::path::PathBuf::from("/src/a.ts");
5302        let files = vec![crate::discover::DiscoveredFile {
5303            id: crate::discover::FileId(0),
5304            path: path_a.clone(),
5305            size_bytes: 100,
5306        }];
5307
5308        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5309            file_id: crate::discover::FileId(0),
5310            path: path_a.clone(),
5311            exports: vec![
5312                fallow_types::extract::ExportInfo {
5313                    name: crate::source::ExportName::Named("a".into()),
5314                    local_name: None,
5315                    is_type_only: false,
5316                    visibility: crate::source::VisibilityTag::None,
5317                    expected_unused_reason: None,
5318                    span: oxc_span::Span::empty(0),
5319                    members: vec![],
5320                    is_side_effect_used: false,
5321                    super_class: None,
5322                    deprecated: false,
5323                    deprecated_reason: None,
5324                },
5325                fallow_types::extract::ExportInfo {
5326                    name: crate::source::ExportName::Named("b".into()),
5327                    local_name: None,
5328                    is_type_only: false,
5329                    visibility: crate::source::VisibilityTag::None,
5330                    expected_unused_reason: None,
5331                    span: oxc_span::Span::empty(0),
5332                    members: vec![],
5333                    is_side_effect_used: false,
5334                    super_class: None,
5335                    deprecated: false,
5336                    deprecated_reason: None,
5337                },
5338                fallow_types::extract::ExportInfo {
5339                    name: crate::source::ExportName::Named("T".into()),
5340                    local_name: None,
5341                    is_type_only: true,
5342                    visibility: crate::source::VisibilityTag::None,
5343                    expected_unused_reason: None,
5344                    span: oxc_span::Span::empty(0),
5345                    members: vec![],
5346                    is_side_effect_used: false,
5347                    super_class: None,
5348                    deprecated: false,
5349                    deprecated_reason: None,
5350                },
5351            ]
5352            .into(),
5353            ..Default::default()
5354        }];
5355
5356        let graph = build_test_graph(&files, &[], &resolved_modules);
5357
5358        let modules = vec![make_module_info(
5359            0,
5360            10,
5361            vec![fallow_types::extract::FunctionComplexity {
5362                name: "fn_a".into(),
5363                is_private_member: false,
5364                line: 1,
5365                col: 0,
5366                cyclomatic: 2,
5367                cognitive: 1,
5368                line_count: 10,
5369                param_count: 0,
5370                react_hook_count: 0,
5371                react_jsx_max_depth: 0,
5372                react_prop_count: 0,
5373                source_hash: None,
5374                contributions: Vec::new(),
5375            }],
5376        )];
5377
5378        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5379            rustc_hash::FxHashMap::default();
5380        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5381
5382        let output = crate::results::DeadCodeAnalysisArtifacts {
5383            results: fallow_types::results::AnalysisResults::default(),
5384            timings: None,
5385            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5386            modules: None,
5387            files: None,
5388            script_used_packages: rustc_hash::FxHashSet::default(),
5389            trace_provenance: crate::trace::TraceProvenance::default(),
5390            file_hashes: rustc_hash::FxHashMap::default(),
5391        };
5392
5393        let result = compute_file_scores_default(
5394            &modules,
5395            &file_paths,
5396            None,
5397            output,
5398            None,
5399            std::path::Path::new("/project"),
5400        )
5401        .unwrap();
5402        assert_eq!(result.value_export_counts[&path_a], 2);
5403    }
5404
5405    #[test]
5406    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
5407        let path_a = std::path::PathBuf::from("/src/simple.ts");
5408        let files = vec![crate::discover::DiscoveredFile {
5409            id: crate::discover::FileId(0),
5410            path: path_a.clone(),
5411            size_bytes: 100,
5412        }];
5413
5414        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5415            file_id: crate::discover::FileId(0),
5416            path: path_a.clone(),
5417            ..Default::default()
5418        }];
5419
5420        let graph = build_test_graph(&files, &[], &resolved_modules);
5421
5422        let modules = vec![make_module_info(
5423            0,
5424            10,
5425            vec![fallow_types::extract::FunctionComplexity {
5426                name: "trivial".into(),
5427                is_private_member: false,
5428                line: 1,
5429                col: 0,
5430                cyclomatic: 1,
5431                cognitive: 0,
5432                line_count: 10,
5433                param_count: 0,
5434                react_hook_count: 0,
5435                react_jsx_max_depth: 0,
5436                react_prop_count: 0,
5437                source_hash: None,
5438                contributions: Vec::new(),
5439            }],
5440        )];
5441
5442        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5443            rustc_hash::FxHashMap::default();
5444        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5445
5446        let output = crate::results::DeadCodeAnalysisArtifacts {
5447            results: fallow_types::results::AnalysisResults::default(),
5448            timings: None,
5449            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5450            modules: None,
5451            files: None,
5452            script_used_packages: rustc_hash::FxHashSet::default(),
5453            trace_provenance: crate::trace::TraceProvenance::default(),
5454            file_hashes: rustc_hash::FxHashMap::default(),
5455        };
5456
5457        let result = compute_file_scores_default(
5458            &modules,
5459            &file_paths,
5460            None,
5461            output,
5462            None,
5463            std::path::Path::new("/project"),
5464        )
5465        .unwrap();
5466        assert!(!result.top_complex_fns.contains_key(&path_a));
5467    }
5468
5469    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
5470        fallow_types::extract::FunctionComplexity {
5471            name: "test_fn".into(),
5472            is_private_member: false,
5473            line: 1,
5474            col: 0,
5475            cyclomatic,
5476            cognitive: 0,
5477            line_count: 10,
5478            param_count: 0,
5479            react_hook_count: 0,
5480            react_jsx_max_depth: 0,
5481            react_prop_count: 0,
5482            source_hash: None,
5483            contributions: Vec::new(),
5484        }
5485    }
5486
5487    fn make_named_fn_complexity(
5488        name: &str,
5489        line: u32,
5490        cyclomatic: u16,
5491    ) -> fallow_types::extract::FunctionComplexity {
5492        fallow_types::extract::FunctionComplexity {
5493            name: name.into(),
5494            is_private_member: false,
5495            line,
5496            col: 0,
5497            cyclomatic,
5498            cognitive: 0,
5499            line_count: 10,
5500            param_count: 0,
5501            react_hook_count: 0,
5502            react_jsx_max_depth: 0,
5503            react_prop_count: 0,
5504            source_hash: None,
5505            contributions: Vec::new(),
5506        }
5507    }
5508
5509    fn crap_override_entry(
5510        files: &[&str],
5511        functions: &[&str],
5512        max_crap: Option<f64>,
5513    ) -> fallow_config::HealthThresholdOverride {
5514        fallow_config::HealthThresholdOverride {
5515            files: files.iter().map(ToString::to_string).collect(),
5516            functions: functions.iter().map(ToString::to_string).collect(),
5517            max_cyclomatic: None,
5518            max_cognitive: None,
5519            max_crap,
5520            max_unit_size: None,
5521            reason: Some("test override".into()),
5522        }
5523    }
5524
5525    fn estimated_signals_with(
5526        resolver: &ThresholdOverrideResolver,
5527        relative: &str,
5528        enforce_crap: bool,
5529        complexity: &[fallow_types::extract::FunctionComplexity],
5530    ) -> CrapThresholdSignals {
5531        let ceilings = CrapCeilingLookup::new(
5532            CrapScoreThresholds {
5533                resolver,
5534                enforce_crap,
5535            },
5536            std::path::Path::new(relative),
5537        );
5538        compute_crap_scores_estimated(
5539            complexity,
5540            &rustc_hash::FxHashSet::default(),
5541            false,
5542            fallow_output::CoverageSource::Estimated,
5543            &ceilings,
5544        )
5545        .signals
5546    }
5547
5548    #[test]
5549    fn crap_counting_exempts_functions_under_override_ceiling() {
5550        // The issue's repro: two untested cyclomatic-10 functions (CRAP 110)
5551        // under an override raising maxCrap to 500 on the file.
5552        let resolver =
5553            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5554        let fns = vec![
5555            make_named_fn_complexity("a", 1, 10),
5556            make_named_fn_complexity("b", 12, 10),
5557        ];
5558
5559        let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5560        assert_eq!(covered.above, 0);
5561        assert_eq!(covered.exempted, 2);
5562        assert_eq!(covered.min_ceiling, Some(500.0));
5563
5564        let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
5565        assert_eq!(elsewhere.above, 2);
5566        assert_eq!(elsewhere.exempted, 0);
5567        assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
5568    }
5569
5570    #[test]
5571    fn crap_counting_insufficient_override_keeps_count() {
5572        let resolver =
5573            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
5574        let fns = vec![
5575            make_named_fn_complexity("a", 1, 10),
5576            make_named_fn_complexity("b", 12, 10),
5577        ];
5578
5579        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5580        assert_eq!(signals.above, 2);
5581        assert_eq!(signals.exempted, 0);
5582        assert_eq!(signals.min_ceiling, Some(50.0));
5583    }
5584
5585    #[test]
5586    fn crap_counting_partial_function_override() {
5587        // Only `a` is exempted; `b` keeps the global ceiling, which is also
5588        // the file's lowest effective ceiling.
5589        let resolver =
5590            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
5591        let fns = vec![
5592            make_named_fn_complexity("a", 1, 10),
5593            make_named_fn_complexity("b", 12, 10),
5594        ];
5595
5596        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5597        assert_eq!(signals.above, 1);
5598        assert_eq!(signals.exempted, 1);
5599        assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
5600    }
5601
5602    #[test]
5603    fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
5604        // Global maxCrap 0 disables enforcement: nothing is above threshold
5605        // and every canonical-baseline breach is disclosed as exempt.
5606        let resolver = test_crap_resolver(0.0);
5607        let fns = vec![
5608            make_named_fn_complexity("a", 1, 10),
5609            make_named_fn_complexity("b", 12, 10),
5610            make_named_fn_complexity("tiny", 24, 1),
5611        ];
5612
5613        let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
5614        assert_eq!(signals.above, 0);
5615        assert_eq!(signals.exempted, 2);
5616    }
5617
5618    #[test]
5619    fn crap_counting_stricter_ceiling_never_counts_exempt() {
5620        // A ceiling below the canonical baseline flags the band between it and
5621        // 30 as above-threshold, never as exempt.
5622        let resolver = test_crap_resolver(10.0);
5623        let fns = vec![make_named_fn_complexity("a", 1, 4)]; // untested CRAP 20
5624
5625        let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
5626        assert_eq!(signals.above, 1);
5627        assert_eq!(signals.exempted, 0);
5628    }
5629
5630    #[test]
5631    fn crap_counting_uses_rounded_value_at_boundary() {
5632        // Istanbul coverage tuned so unrounded CRAP is 29.96, which rounds to
5633        // the stored per-function 30.0. The findings pipeline compares the
5634        // ROUNDED value against the ceiling and emits a finding; the count
5635        // must agree at the same boundary (issue #2228).
5636        let funcs = vec![make_fn_complexity(10)];
5637        let mut functions = rustc_hash::FxHashMap::default();
5638        functions.insert(("test_fn".to_string(), 1, 0), 41.56);
5639        let file_cov = test_istanbul_file_coverage(functions, false);
5640
5641        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5642        assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
5643        assert_eq!(result.signals.above, 1);
5644        assert_eq!(result.signals.exempted, 0);
5645    }
5646
5647    #[test]
5648    fn compute_file_scores_discloses_override_exemption_on_row() {
5649        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5650        let files = vec![crate::discover::DiscoveredFile {
5651            id: crate::discover::FileId(0),
5652            path: path_a.clone(),
5653            size_bytes: 100,
5654        }];
5655        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5656            file_id: crate::discover::FileId(0),
5657            path: path_a.clone(),
5658            ..Default::default()
5659        }];
5660        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5661        let modules = vec![make_module_info(
5662            0,
5663            26,
5664            vec![
5665                make_named_fn_complexity("a", 1, 10),
5666                make_named_fn_complexity("b", 12, 10),
5667            ],
5668        )];
5669        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5670            rustc_hash::FxHashMap::default();
5671        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5672        let output = crate::results::DeadCodeAnalysisArtifacts {
5673            results: fallow_types::results::AnalysisResults::default(),
5674            timings: None,
5675            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5676            modules: None,
5677            files: None,
5678            script_used_packages: rustc_hash::FxHashSet::default(),
5679            trace_provenance: crate::trace::TraceProvenance::default(),
5680            file_hashes: rustc_hash::FxHashMap::default(),
5681        };
5682
5683        let resolver =
5684            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5685        let result = compute_file_scores(
5686            FileScoreComputeInput {
5687                modules: &modules,
5688                file_paths: &file_paths,
5689                changed_files: None,
5690                istanbul_coverage: None,
5691                root: std::path::Path::new("/project"),
5692                crap_thresholds: CrapScoreThresholds {
5693                    resolver: &resolver,
5694                    enforce_crap: true,
5695                },
5696            },
5697            output,
5698        )
5699        .unwrap();
5700
5701        assert_eq!(result.scores.len(), 1);
5702        let score = &result.scores[0];
5703        assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
5704        assert_eq!(score.crap_above_threshold, 0);
5705        assert_eq!(score.crap_exempted, 2);
5706        assert_eq!(score.crap_effective_threshold, Some(500.0));
5707        assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
5708        assert_eq!(
5709            file_score_concern_axis(score, CRAP_THRESHOLD),
5710            FileScoreConcern::Structural
5711        );
5712    }
5713
5714    #[test]
5715    fn compute_file_scores_raised_global_omits_row_threshold() {
5716        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5717        let files = vec![crate::discover::DiscoveredFile {
5718            id: crate::discover::FileId(0),
5719            path: path_a.clone(),
5720            size_bytes: 100,
5721        }];
5722        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5723            file_id: crate::discover::FileId(0),
5724            path: path_a.clone(),
5725            ..Default::default()
5726        }];
5727        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5728        let modules = vec![make_module_info(
5729            0,
5730            26,
5731            vec![
5732                make_named_fn_complexity("a", 1, 10),
5733                make_named_fn_complexity("b", 12, 10),
5734            ],
5735        )];
5736        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5737            rustc_hash::FxHashMap::default();
5738        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5739        let output = crate::results::DeadCodeAnalysisArtifacts {
5740            results: fallow_types::results::AnalysisResults::default(),
5741            timings: None,
5742            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5743            modules: None,
5744            files: None,
5745            script_used_packages: rustc_hash::FxHashSet::default(),
5746            trace_provenance: crate::trace::TraceProvenance::default(),
5747            file_hashes: rustc_hash::FxHashMap::default(),
5748        };
5749
5750        // Raised via the global (`--max-crap 5000`), not an override: the row
5751        // must not repeat the run global as its own effective threshold.
5752        let resolver = test_crap_resolver(5000.0);
5753        let result = compute_file_scores(
5754            FileScoreComputeInput {
5755                modules: &modules,
5756                file_paths: &file_paths,
5757                changed_files: None,
5758                istanbul_coverage: None,
5759                root: std::path::Path::new("/project"),
5760                crap_thresholds: CrapScoreThresholds {
5761                    resolver: &resolver,
5762                    enforce_crap: true,
5763                },
5764            },
5765            output,
5766        )
5767        .unwrap();
5768
5769        assert_eq!(result.scores.len(), 1);
5770        let score = &result.scores[0];
5771        assert_eq!(score.crap_above_threshold, 0);
5772        assert_eq!(score.crap_exempted, 2);
5773        assert_eq!(score.crap_effective_threshold, None);
5774        assert!(file_score_fully_crap_exempt(score, 5000.0));
5775        assert_eq!(
5776            file_score_concern_axis(score, 5000.0),
5777            FileScoreConcern::Structural
5778        );
5779    }
5780
5781    /// Untested-file aggregation on the shipped estimated path: the reported
5782    /// max is the highest rounded score, `above` counts only the units at or
5783    /// over their effective ceiling, and the synthetic template and module
5784    /// units leave the CRAP dimension entirely.
5785    #[test]
5786    fn estimated_crap_untested_aggregates_over_real_units_only() {
5787        let funcs = vec![
5788            make_named_fn_complexity("below", 1, 4),
5789            make_named_fn_complexity("at_threshold", 2, 5),
5790            make_named_fn_complexity("above_threshold", 3, 8),
5791            make_named_fn_complexity("<template>", 4, 21),
5792            make_named_fn_complexity("<module>", 5, 21),
5793        ];
5794        let result = estimated_crap_default(
5795            &funcs,
5796            &rustc_hash::FxHashSet::default(),
5797            false,
5798            fallow_output::CoverageSource::Estimated,
5799        );
5800        assert!((result.max_crap - 72.0).abs() < f64::EPSILON, "{result:#?}");
5801        assert_eq!(result.signals.above, 2);
5802        assert_eq!(result.per_function.len(), 3);
5803    }
5804
5805    #[test]
5806    fn crap_formula_full_coverage() {
5807        let result = crap_formula(10.0, 100.0);
5808        assert!((result - 10.0).abs() < f64::EPSILON);
5809    }
5810
5811    #[test]
5812    fn crap_formula_zero_coverage() {
5813        let result = crap_formula(5.0, 0.0);
5814        assert!((result - 30.0).abs() < f64::EPSILON);
5815    }
5816
5817    #[test]
5818    fn crap_formula_partial_coverage() {
5819        let result = crap_formula(10.0, 50.0);
5820        assert!((result - 22.5).abs() < f64::EPSILON);
5821    }
5822
5823    #[test]
5824    fn crap_formula_high_coverage_low_complexity() {
5825        let result = crap_formula(2.0, 90.0);
5826        assert!((result - 2.004).abs() < 0.001);
5827    }
5828
5829    /// Pin the exact cyclomatic value at which the default CRAP gate (30.0)
5830    /// trips for each estimated-coverage tier. These numbers back the
5831    /// changelog and docs wording: 5 at the 0% tier, 10 at the 40% indirect
5832    /// tier, 28 at the 85% direct tier.
5833    #[test]
5834    fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
5835        for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
5836            assert!(
5837                crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
5838                "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
5839            );
5840            assert!(
5841                crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
5842                "cyclomatic {} at {coverage_pct}% must stay under the gate",
5843                gate_cc - 1.0
5844            );
5845        }
5846    }
5847
5848    #[test]
5849    fn istanbul_crap_excludes_synthetic_template_units() {
5850        let funcs = vec![
5851            make_named_fn_complexity("<template>", 1, 21),
5852            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5853            make_fn_complexity(6),
5854        ];
5855        let result = istanbul_crap_default(&funcs, None, false);
5856        assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
5857        assert_eq!(result.signals.above, 1);
5858        assert_eq!(
5859            result.total, 1,
5860            "template units must not count as unmatched"
5861        );
5862        assert_eq!(result.per_function.len(), 1);
5863    }
5864
5865    #[test]
5866    fn estimated_crap_excludes_synthetic_template_units() {
5867        let funcs = vec![
5868            make_named_fn_complexity("<template>", 1, 21),
5869            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5870        ];
5871        let result = estimated_crap_default(
5872            &funcs,
5873            &rustc_hash::FxHashSet::default(),
5874            false,
5875            fallow_output::CoverageSource::Estimated,
5876        );
5877        assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
5878        assert_eq!(result.signals.above, 0);
5879        assert!(result.per_function.is_empty());
5880    }
5881
5882    #[test]
5883    fn istanbul_crap_with_coverage_data() {
5884        let funcs = vec![make_fn_complexity(10)];
5885        let mut functions = rustc_hash::FxHashMap::default();
5886        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
5887        let file_cov = test_istanbul_file_coverage(functions, false);
5888        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5889        assert!((result.max_crap - 10.8).abs() < 0.1);
5890        assert_eq!(result.signals.above, 0);
5891    }
5892
5893    #[test]
5894    fn istanbul_crap_falls_back_to_binary_when_no_match() {
5895        let funcs = vec![make_fn_complexity(6)];
5896        let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
5897        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5898        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
5899        assert_eq!(result.signals.above, 1);
5900    }
5901
5902    /// A file tests reach, with no coverage data for it at all, keeps the
5903    /// static estimate rather than being scored as fully covered.
5904    #[test]
5905    fn istanbul_crap_uses_the_static_estimate_when_no_file_coverage() {
5906        let funcs = vec![make_fn_complexity(5)];
5907        let result = istanbul_crap_default(&funcs, None, true);
5908        // The reported score is rounded to one decimal, so compare against
5909        // the estimate rather than the formula's last bit.
5910        assert!((result.max_crap - 10.4).abs() < 1e-9);
5911        assert_eq!(result.signals.above, 0);
5912    }
5913
5914    #[test]
5915    fn istanbul_crap_zero_coverage_matches_binary_untested() {
5916        let funcs = vec![make_fn_complexity(5)];
5917        let mut functions = rustc_hash::FxHashMap::default();
5918        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
5919        let file_cov = test_istanbul_file_coverage(functions, false);
5920        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5921        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
5922        assert_eq!(result.signals.above, 1);
5923    }
5924
5925    #[test]
5926    fn estimated_crap_direct_test_reference() {
5927        let funcs = vec![make_fn_complexity(10)];
5928        let mut refs = rustc_hash::FxHashSet::default();
5929        refs.insert("test_fn".to_string());
5930        let result = estimated_crap_default(
5931            &funcs,
5932            &refs,
5933            true,
5934            fallow_output::CoverageSource::Estimated,
5935        );
5936        let (max, above) = (result.max_crap, result.signals.above);
5937        assert!((max - 10.3).abs() < 0.1);
5938        assert_eq!(above, 0);
5939    }
5940
5941    #[test]
5942    fn estimated_crap_indirect_test_reachable() {
5943        let funcs = vec![make_fn_complexity(10)];
5944        let refs = rustc_hash::FxHashSet::default();
5945        let result = estimated_crap_default(
5946            &funcs,
5947            &refs,
5948            true,
5949            fallow_output::CoverageSource::Estimated,
5950        );
5951        let (max, above) = (result.max_crap, result.signals.above);
5952        assert!((max - 31.6).abs() < 0.1);
5953        assert_eq!(above, 1);
5954    }
5955
5956    #[test]
5957    fn estimated_crap_untested_file() {
5958        let funcs = vec![make_fn_complexity(5)];
5959        let refs = rustc_hash::FxHashSet::default();
5960        let result = estimated_crap_default(
5961            &funcs,
5962            &refs,
5963            false,
5964            fallow_output::CoverageSource::Estimated,
5965        );
5966        let (max, above) = (result.max_crap, result.signals.above);
5967        assert!((max - 30.0).abs() < f64::EPSILON);
5968        assert_eq!(above, 1);
5969    }
5970
5971    #[test]
5972    fn estimated_crap_low_complexity_direct_ref() {
5973        let funcs = vec![make_fn_complexity(2)];
5974        let mut refs = rustc_hash::FxHashSet::default();
5975        refs.insert("test_fn".to_string());
5976        let result = estimated_crap_default(
5977            &funcs,
5978            &refs,
5979            true,
5980            fallow_output::CoverageSource::Estimated,
5981        );
5982        let (max, above) = (result.max_crap, result.signals.above);
5983        assert!(max < 3.0);
5984        assert_eq!(above, 0);
5985    }
5986
5987    #[test]
5988    fn estimated_crap_empty() {
5989        let refs = rustc_hash::FxHashSet::default();
5990        let result =
5991            estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
5992        let (max, above) = (result.max_crap, result.signals.above);
5993        assert!((max).abs() < f64::EPSILON);
5994        assert_eq!(above, 0);
5995    }
5996
5997    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
5998        fallow_graph::graph::ExportSymbol {
5999            name: fallow_types::extract::ExportName::Named(name.into()),
6000            is_type_only,
6001            is_side_effect_used: false,
6002            visibility: crate::source::VisibilityTag::None,
6003            expected_unused_reason: None,
6004            span: oxc_span::Span::default(),
6005            references: vec![],
6006            reference_paths: Vec::new(),
6007            members: vec![],
6008            deprecated: false,
6009            deprecated_reason: None,
6010        }
6011    }
6012
6013    #[test]
6014    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
6015        let path = std::path::Path::new("src/types.ts");
6016        let exports = vec![
6017            make_export("MyInterface", true),
6018            make_export("MyType", true),
6019            make_export("myFunction", false),
6020        ];
6021        let unused_files = rustc_hash::FxHashSet::default();
6022        let mut unused_by_path = rustc_hash::FxHashMap::default();
6023        unused_by_path.insert(path, 1_usize); // 1 unused value export
6024
6025        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6026        assert!((ratio - 1.0).abs() < f64::EPSILON);
6027    }
6028
6029    #[test]
6030    fn dead_code_ratio_only_type_exports_returns_zero() {
6031        let path = std::path::Path::new("src/types.ts");
6032        let exports = vec![
6033            make_export("MyInterface", true),
6034            make_export("MyType", true),
6035        ];
6036        let unused_files = rustc_hash::FxHashSet::default();
6037        let unused_by_path = rustc_hash::FxHashMap::default();
6038
6039        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6040        assert!(ratio.abs() < f64::EPSILON);
6041    }
6042
6043    #[test]
6044    fn dead_code_ratio_mixed_exports_counts_only_values() {
6045        let path = std::path::Path::new("src/component.ts");
6046        let exports = vec![
6047            make_export("Props", true),
6048            make_export("State", true),
6049            make_export("Component", false),
6050            make_export("helper", false),
6051        ];
6052        let unused_files = rustc_hash::FxHashSet::default();
6053        let mut unused_by_path = rustc_hash::FxHashMap::default();
6054        unused_by_path.insert(path, 1_usize);
6055
6056        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6057        assert!((ratio - 0.5).abs() < f64::EPSILON);
6058    }
6059
6060    fn write_single_file_istanbul_fixture(
6061        coverage_path: &std::path::Path,
6062        source_path: &std::path::Path,
6063        fn_map: &serde_json::Value,
6064        function_hits: &serde_json::Value,
6065    ) {
6066        write_single_file_istanbul_fixture_with_statements(
6067            coverage_path,
6068            source_path,
6069            fn_map,
6070            function_hits,
6071            &serde_json::json!({}),
6072            &serde_json::json!({}),
6073        );
6074    }
6075
6076    fn write_single_file_istanbul_fixture_with_statements(
6077        coverage_path: &std::path::Path,
6078        source_path: &std::path::Path,
6079        fn_map: &serde_json::Value,
6080        function_hits: &serde_json::Value,
6081        statement_map: &serde_json::Value,
6082        statement_hits: &serde_json::Value,
6083    ) {
6084        let mut root = serde_json::Map::new();
6085        root.insert(
6086            source_path.to_string_lossy().into_owned(),
6087            serde_json::json!({
6088                "path": source_path.to_string_lossy().into_owned(),
6089                "statementMap": statement_map,
6090                "fnMap": fn_map,
6091                "branchMap": {},
6092                "s": statement_hits,
6093                "f": function_hits,
6094                "b": {}
6095            }),
6096        );
6097
6098        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
6099    }
6100
6101    /// `v8-to-istanbul`, which is what c8 and nyc write, records `column: -1`
6102    /// for the implicit else of a bare `if`. Positions are unsigned, so the
6103    /// strict parse rejected the whole map over a coordinate in a section
6104    /// nothing here reads. Geometry from a map generated by running real V8
6105    /// coverage through v8-to-istanbul 9.2.0.
6106    #[test]
6107    fn a_negative_branch_coordinate_does_not_cost_the_whole_map() {
6108        let temp = tempfile::TempDir::new().unwrap();
6109        let source_path = temp.path().join("src/pick.ts");
6110        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6111        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6112
6113        let coverage_path = temp.path().join("coverage-final.json");
6114        let source = source_path.to_string_lossy().into_owned();
6115        std::fs::write(
6116            &coverage_path,
6117            serde_json::to_string(&serde_json::json!({
6118                source.clone(): {
6119                    "path": source,
6120                    "statementMap": {},
6121                    "fnMap": {
6122                        "0": {
6123                            "name": "pick",
6124                            "line": 1,
6125                            "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 20 } },
6126                            "loc": { "start": { "line": 1, "column": 41 }, "end": { "line": 6, "column": 1 } }
6127                        }
6128                    },
6129                    "branchMap": {
6130                        "0": {
6131                            "type": "branch",
6132                            "line": 5,
6133                            "loc": {
6134                                "start": { "line": 5, "column": -1 },
6135                                "end": { "line": 6, "column": 0 }
6136                            },
6137                            "locations": [
6138                                {
6139                                    "start": { "line": 5, "column": -1 },
6140                                    "end": { "line": 6, "column": 0 }
6141                                }
6142                            ]
6143                        }
6144                    },
6145                    "s": {},
6146                    "f": { "0": 2 },
6147                    "b": { "0": [1, 0] }
6148                }
6149            }))
6150            .unwrap(),
6151        )
6152        .unwrap();
6153
6154        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6155        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6156        let file_coverage = coverage.get(&canonical_source).unwrap();
6157
6158        assert_eq!(file_coverage.lookup("pick", 1, 16), Some(100.0));
6159    }
6160
6161    /// Raw V8 coverage and `oxc-coverage-instrument` record an accessor as
6162    /// `get area`, istanbul-lib-instrument leaves it anonymous, and fallow
6163    /// extracts the unit as `area`. Both spellings must reach the record.
6164    #[test]
6165    fn an_accessor_answers_to_its_property_name() {
6166        let temp = tempfile::TempDir::new().unwrap();
6167        let source_path = temp.path().join("src/box.ts");
6168        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6169        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6170
6171        let coverage_path = temp.path().join("coverage-final.json");
6172        write_single_file_istanbul_fixture(
6173            &coverage_path,
6174            &source_path,
6175            &serde_json::json!({
6176                "0": {
6177                    "name": "get area",
6178                    "line": 4,
6179                    "decl": { "start": { "line": 4, "column": 6 }, "end": { "line": 4, "column": 10 } },
6180                    "loc": { "start": { "line": 4, "column": 13 }, "end": { "line": 6, "column": 3 } }
6181                }
6182            }),
6183            &serde_json::json!({ "0": 0 }),
6184        );
6185
6186        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6187        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6188        let file_coverage = coverage.get(&canonical_source).unwrap();
6189
6190        // Fallow extracts the accessor under the property name alone.
6191        assert_eq!(file_coverage.lookup("area", 4, 6), Some(0.0));
6192        // The producer's own spelling still resolves.
6193        assert_eq!(file_coverage.lookup("get area", 4, 6), Some(0.0));
6194    }
6195
6196    #[test]
6197    fn resolve_relative_to_root_joins_relative_with_project_root() {
6198        let resolved = resolve_relative_to_root(
6199            std::path::Path::new("coverage/coverage-final.json"),
6200            Some(std::path::Path::new("/work/my-app")),
6201        );
6202        assert_eq!(
6203            resolved,
6204            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
6205        );
6206    }
6207
6208    #[test]
6209    fn resolve_relative_to_root_returns_absolute_unchanged() {
6210        let resolved = resolve_relative_to_root(
6211            std::path::Path::new("/tmp/coverage-final.json"),
6212            Some(std::path::Path::new("/work/my-app")),
6213        );
6214        assert_eq!(
6215            resolved,
6216            std::path::PathBuf::from("/tmp/coverage-final.json")
6217        );
6218    }
6219
6220    #[test]
6221    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
6222        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
6223        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
6224        assert_eq!(resolved, path);
6225    }
6226
6227    #[cfg(windows)]
6228    #[test]
6229    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
6230        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
6231        let resolved =
6232            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
6233        assert_eq!(resolved, path);
6234    }
6235
6236    #[test]
6237    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
6238        let resolved =
6239            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
6240        assert_eq!(
6241            resolved,
6242            std::path::PathBuf::from("coverage/coverage-final.json")
6243        );
6244    }
6245
6246    /// nyc and some Jest setups record project-relative keys. Resolving one
6247    /// against the process directory misses the file, and a run from anywhere
6248    /// but the project root loses the whole map at once.
6249    #[test]
6250    fn load_istanbul_coverage_resolves_relative_map_keys_against_project_root() {
6251        let temp = tempfile::TempDir::new().unwrap();
6252        let source_path = temp.path().join("src/index.ts");
6253        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6254        std::fs::write(&source_path, "export function f(){}").unwrap();
6255
6256        let coverage_path = temp.path().join("coverage-final.json");
6257        std::fs::write(
6258            &coverage_path,
6259            serde_json::to_string(&serde_json::json!({
6260                "src/index.ts": {
6261                    "path": "src/index.ts",
6262                    "statementMap": {},
6263                    "fnMap": {
6264                        "0": {
6265                            "name": "f",
6266                            "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6267                            "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6268                        }
6269                    },
6270                    "branchMap": {},
6271                    "s": {},
6272                    "f": { "0": 2 },
6273                    "b": {}
6274                }
6275            }))
6276            .unwrap(),
6277        )
6278        .unwrap();
6279
6280        let coverage =
6281            load_istanbul_coverage(&coverage_path, None, Some(temp.path()), false).unwrap();
6282        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6283        let file_coverage = coverage.get(&canonical_source).unwrap();
6284
6285        assert_eq!(file_coverage.lookup("f", 1, 0), Some(100.0));
6286    }
6287
6288    #[test]
6289    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
6290        let temp = tempfile::TempDir::new().unwrap();
6291        let source_path = temp.path().join("src/index.ts");
6292        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6293        std::fs::write(&source_path, "export function f(){}").unwrap();
6294
6295        let coverage_path = temp.path().join("coverage/coverage-final.json");
6296        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
6297        write_single_file_istanbul_fixture(
6298            &coverage_path,
6299            &source_path,
6300            &serde_json::json!({
6301                "0": {
6302                    "name": "f",
6303                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6304                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6305                }
6306            }),
6307            &serde_json::json!({ "0": 1 }),
6308        );
6309
6310        let coverage = load_istanbul_coverage(
6311            std::path::Path::new("coverage/coverage-final.json"),
6312            None,
6313            Some(temp.path()),
6314            false,
6315        )
6316        .expect("relative path must resolve against project_root");
6317        assert!(
6318            !coverage.files.is_empty(),
6319            "expected coverage to load via project_root resolution, got {} files",
6320            coverage.files.len()
6321        );
6322    }
6323
6324    #[test]
6325    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
6326        let temp = tempfile::TempDir::new().unwrap();
6327        let source_path = temp.path().join("src/service.ts");
6328        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6329        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
6330
6331        let coverage_path = temp.path().join("coverage-final.json");
6332        write_single_file_istanbul_fixture(
6333            &coverage_path,
6334            &source_path,
6335            &serde_json::json!({
6336                "0": {
6337                    "name": "(anonymous_0)",
6338                    "decl": {
6339                        "start": { "line": 5, "column": 2 },
6340                        "end": { "line": 5, "column": 13 }
6341                    },
6342                    "loc": {
6343                        "start": { "line": 5, "column": 14 },
6344                        "end": { "line": 11, "column": 3 }
6345                    }
6346                },
6347                "1": {
6348                    "name": "(anonymous_1)",
6349                    "decl": {
6350                        "start": { "line": 20, "column": 14 },
6351                        "end": { "line": 20, "column": 25 }
6352                    },
6353                    "loc": {
6354                        "start": { "line": 20, "column": 28 },
6355                        "end": { "line": 22, "column": 2 }
6356                    }
6357                }
6358            }),
6359            &serde_json::json!({
6360                "0": 1,
6361                "1": 0
6362            }),
6363        );
6364
6365        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6366        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6367        let file_coverage = coverage.get(&canonical_source).unwrap();
6368
6369        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
6370        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
6371    }
6372
6373    #[test]
6374    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
6375        let temp = tempfile::TempDir::new().unwrap();
6376        let source_path = temp.path().join("src/handler.ts");
6377        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6378        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
6379
6380        let coverage_path = temp.path().join("coverage-final.json");
6381        write_single_file_istanbul_fixture(
6382            &coverage_path,
6383            &source_path,
6384            &serde_json::json!({
6385                "0": {
6386                    "name": "handleClick",
6387                    "line": 40,
6388                    "decl": {
6389                        "start": { "line": 22, "column": 13 },
6390                        "end": { "line": 22, "column": 24 }
6391                    },
6392                    "loc": {
6393                        "start": { "line": 40, "column": 27 },
6394                        "end": { "line": 42, "column": 1 }
6395                    }
6396                }
6397            }),
6398            &serde_json::json!({
6399                "0": 1
6400            }),
6401        );
6402
6403        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6404        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6405        let file_coverage = coverage.get(&canonical_source).unwrap();
6406
6407        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
6408        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
6409    }
6410
6411    #[test]
6412    fn load_istanbul_coverage_indexes_valid_body_start_alias() {
6413        let temp = tempfile::TempDir::new().unwrap();
6414        let source_path = temp.path().join("src/handler.ts");
6415        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6416        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
6417
6418        let coverage_path = temp.path().join("coverage-final.json");
6419        write_single_file_istanbul_fixture(
6420            &coverage_path,
6421            &source_path,
6422            &serde_json::json!({
6423                "0": {
6424                    "name": "(anonymous_0)",
6425                    "line": 8,
6426                    "decl": {
6427                        "start": { "line": 8, "column": 14 },
6428                        "end": { "line": 8, "column": 25 }
6429                    },
6430                    "loc": {
6431                        "start": { "line": 20, "column": 6 },
6432                        "end": { "line": 22, "column": 1 }
6433                    }
6434                }
6435            }),
6436            &serde_json::json!({ "0": 1 }),
6437        );
6438
6439        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6440        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6441        let file_coverage = coverage.get(&canonical_source).unwrap();
6442
6443        assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
6444
6445        let mut function = make_fn_complexity(4);
6446        function.name = "handler".to_string();
6447        function.line = 20;
6448        function.col = 6;
6449        let result = istanbul_crap_default(&[function], Some(file_coverage), false);
6450        assert_eq!(result.matched, 1);
6451        assert_eq!(result.total, 1);
6452        assert_eq!(
6453            result.per_function[0].coverage_source,
6454            fallow_output::CoverageSource::Istanbul
6455        );
6456        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6457    }
6458
6459    /// Curried arrows written one per line put each record's body start on
6460    /// the next record's declaration. The declaration is primary and wins the
6461    /// position, so each arrow keeps a record of its own and the innermost one
6462    /// resolves through the header span that opens at the arrow above it.
6463    /// Geometry from istanbul-lib-instrument 6 for the source below, which is
6464    /// what Prettier produces for a curried arrow.
6465    #[test]
6466    fn curried_arrows_one_per_line_each_take_their_own_record() {
6467        let temp = tempfile::TempDir::new().unwrap();
6468        let source_path = temp.path().join("src/adjust.ts");
6469        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6470        std::fs::write(
6471            &source_path,
6472            "export const adjust = (base: number) =>\n  (factor: number) =>\n  (offset: number) =>\n    base * factor + offset;\n",
6473        )
6474        .unwrap();
6475
6476        let coverage_path = temp.path().join("coverage-final.json");
6477        write_single_file_istanbul_fixture(
6478            &coverage_path,
6479            &source_path,
6480            &serde_json::json!({
6481                "0": {
6482                    "name": "(anonymous_0)",
6483                    "line": 2,
6484                    "decl": { "start": { "line": 1, "column": 22 }, "end": { "line": 1, "column": 23 } },
6485                    "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 26 } }
6486                },
6487                "1": {
6488                    "name": "(anonymous_1)",
6489                    "line": 3,
6490                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6491                    "loc": { "start": { "line": 3, "column": 2 }, "end": { "line": 4, "column": 26 } }
6492                },
6493                "2": {
6494                    "name": "(anonymous_2)",
6495                    "line": 4,
6496                    "decl": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 3 } },
6497                    "loc": { "start": { "line": 4, "column": 4 }, "end": { "line": 4, "column": 26 } }
6498                }
6499            }),
6500            &serde_json::json!({ "0": 2, "1": 1, "2": 0 }),
6501        );
6502
6503        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6504        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6505        let file_coverage = coverage.get(&canonical_source).unwrap();
6506
6507        assert_eq!(file_coverage.lookup("adjust", 1, 22), Some(100.0));
6508        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(100.0));
6509        // The innermost arrow never ran, and takes neither neighbour's value.
6510        assert_eq!(file_coverage.lookup("<arrow>", 3, 2), Some(0.0));
6511    }
6512
6513    /// A default value in a parameter list is inside the member's signature,
6514    /// and its own record can be anchored at the parameter rather than at the
6515    /// function, putting the extracted position tens of columns from the
6516    /// declaration. The record still owns that position, because its own
6517    /// signature span covers it. Geometry from an @vitest/coverage-istanbul
6518    /// map for a constructor whose parameter carries a default arrow.
6519    #[test]
6520    fn a_default_value_keeps_its_own_record_inside_the_enclosing_signature() {
6521        let temp = tempfile::TempDir::new().unwrap();
6522        let source_path = temp.path().join("src/filter.ts");
6523        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6524        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6525
6526        let coverage_path = temp.path().join("coverage-final.json");
6527        write_single_file_istanbul_fixture(
6528            &coverage_path,
6529            &source_path,
6530            &serde_json::json!({
6531                "0": {
6532                    "name": "(anonymous_0)",
6533                    "line": 173,
6534                    "decl": { "start": { "line": 169, "column": 2 }, "end": { "line": 170, "column": 3 } },
6535                    "loc": { "start": { "line": 173, "column": 4 }, "end": { "line": 182, "column": 3 } }
6536                },
6537                "1": {
6538                    "name": "(anonymous_1)",
6539                    "line": 171,
6540                    "decl": { "start": { "line": 171, "column": 21 }, "end": { "line": 171, "column": 67 } },
6541                    "loc": { "start": { "line": 171, "column": 67 }, "end": { "line": 171, "column": 76 } }
6542                }
6543            }),
6544            &serde_json::json!({ "0": 46, "1": 0 }),
6545        );
6546
6547        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6548        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6549        let file_coverage = coverage.get(&canonical_source).unwrap();
6550
6551        // Fallow extracts the arrow at its parameter paren, 40 columns right
6552        // of the record's declaration but inside the record's own span.
6553        assert_eq!(file_coverage.lookup("<arrow>", 171, 61), Some(0.0));
6554    }
6555
6556    /// A named function in a signature is a second function inside the
6557    /// member's header span, so the span identifies nothing on its own. A unit
6558    /// with no record of its own, such as a private member or a unit the
6559    /// producer names differently, must take the estimate rather than the
6560    /// coverage of whichever record happens to be near. Geometry from
6561    /// istanbul-lib-instrument 6 for the source below.
6562    #[test]
6563    fn header_span_abstains_when_another_function_is_declared_inside_it() {
6564        let temp = tempfile::TempDir::new().unwrap();
6565        let source_path = temp.path().join("src/chart.ts");
6566        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6567        std::fs::write(
6568            &source_path,
6569            "export class Chart {\n  @Watch(\"data\")\n  render(\n    rows: number[],\n    project = function scale(\n      row: number\n    ) {\n      return row * 2;\n    }\n  ) {\n    return rows.map(project);\n  }\n}\n",
6570        )
6571        .unwrap();
6572
6573        let coverage_path = temp.path().join("coverage-final.json");
6574        write_single_file_istanbul_fixture(
6575            &coverage_path,
6576            &source_path,
6577            &serde_json::json!({
6578                "0": {
6579                    "name": "(anonymous_0)",
6580                    "line": 10,
6581                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6582                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6583                },
6584                "1": {
6585                    "name": "scale",
6586                    "line": 7,
6587                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 28 } },
6588                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6589                }
6590            }),
6591            &serde_json::json!({ "0": 3, "1": 0 }),
6592        );
6593
6594        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6595        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6596        let coverage = load_istanbul_coverage_for_sources(
6597            &coverage_path,
6598            None,
6599            Some(temp.path()),
6600            Some(&discovered_sources),
6601            false,
6602        )
6603        .unwrap();
6604        let file_coverage = coverage.get(&canonical_source).unwrap();
6605
6606        // Inside both the member's header span and `scale`'s, so neither says
6607        // anything about this position.
6608        assert_eq!(file_coverage.lookup("<anonymous>", 6, 6), None);
6609        // Both records still resolve.
6610        assert_eq!(file_coverage.lookup("scale", 5, 14), Some(0.0));
6611        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6612    }
6613
6614    /// A named function in a member signature can legally reuse the member's
6615    /// name. Name fuzz must not return that inner function before established
6616    /// anonymous resolution preserves the member's valid attribution.
6617    #[test]
6618    fn same_named_function_in_signature_does_not_supply_member_coverage() {
6619        let temp = tempfile::TempDir::new().unwrap();
6620        let source_path = temp.path().join("src/chart.ts");
6621        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6622        std::fs::write(
6623            &source_path,
6624            "export class Chart {\n  @Watch(\"data\")\n  render(\n    rows: number[],\n    project = function render(\n      row: number\n    ) {\n      return row * 2;\n    }\n  ) {\n    return rows.map(project);\n  }\n}\n",
6625        )
6626        .unwrap();
6627
6628        let coverage_path = temp.path().join("coverage-final.json");
6629        write_single_file_istanbul_fixture(
6630            &coverage_path,
6631            &source_path,
6632            &serde_json::json!({
6633                "0": {
6634                    "name": "(anonymous_0)",
6635                    "line": 10,
6636                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6637                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6638                },
6639                "1": {
6640                    "name": "render",
6641                    "line": 7,
6642                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 29 } },
6643                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6644                }
6645            }),
6646            &serde_json::json!({ "0": 3, "1": 0 }),
6647        );
6648
6649        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6650        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6651        let file_coverage = coverage.get(&canonical_source).unwrap();
6652
6653        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6654        assert_eq!(file_coverage.lookup("render", 5, 23), Some(0.0));
6655    }
6656
6657    /// A same-line signature can place an unrelated identifier exactly one
6658    /// function-expression prefix away from the member start. Column distance
6659    /// alone must not make that nested function the member's coverage source.
6660    #[test]
6661    fn same_line_same_named_function_in_signature_does_not_supply_member_coverage() {
6662        let temp = tempfile::TempDir::new().unwrap();
6663        let source_path = temp.path().join("src/chart.ts");
6664        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6665        std::fs::write(
6666            &source_path,
6667            "export class Chart {\n  @Watch(\"data\") render(x  = function  render() {}) {}\n}\n",
6668        )
6669        .unwrap();
6670
6671        let coverage_path = temp.path().join("coverage-final.json");
6672        write_single_file_istanbul_fixture(
6673            &coverage_path,
6674            &source_path,
6675            &serde_json::json!({
6676                "0": {
6677                    "name": "(anonymous_0)",
6678                    "line": 2,
6679                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6680                    "loc": { "start": { "line": 2, "column": 52 }, "end": { "line": 2, "column": 54 } }
6681                },
6682                "1": {
6683                    "name": "render",
6684                    "line": 2,
6685                    "decl": { "start": { "line": 2, "column": 39 }, "end": { "line": 2, "column": 45 } },
6686                    "loc": { "start": { "line": 2, "column": 48 }, "end": { "line": 2, "column": 50 } }
6687                }
6688            }),
6689            &serde_json::json!({ "0": 3, "1": 0 }),
6690        );
6691
6692        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6693        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6694        let coverage = load_istanbul_coverage_for_sources(
6695            &coverage_path,
6696            None,
6697            Some(temp.path()),
6698            Some(&discovered_sources),
6699            false,
6700        )
6701        .unwrap();
6702        let file_coverage = coverage.get(&canonical_source).unwrap();
6703
6704        assert_eq!(file_coverage.lookup("render", 2, 23), Some(100.0));
6705        assert_eq!(file_coverage.lookup("render", 2, 29), Some(0.0));
6706    }
6707
6708    #[test]
6709    fn named_generator_alias_handles_trivia_and_utf16_columns() {
6710        let temp = tempfile::TempDir::new().unwrap();
6711        let source_path = temp.path().join("src/chart.ts");
6712        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6713        let source_line =
6714            "  render(label = \"pi: π, mushroom: 🍄\", x = function/* gap */*render() {}) {}";
6715        std::fs::write(
6716            &source_path,
6717            format!("export class Chart {{\n{source_line}\n}}\n"),
6718        )
6719        .unwrap();
6720
6721        let utf16_column = |byte_column: usize| {
6722            u32::try_from(source_line[..byte_column].encode_utf16().count()).unwrap()
6723        };
6724        let outer_target_column = source_line.find("render(").unwrap() + "render".len();
6725        let syntax_column = source_line.find("function").unwrap();
6726        let name_column = source_line.find("*render").unwrap() + 1;
6727        let inner_body_column = source_line.find("{}").unwrap();
6728        let outer_body_column = source_line.rfind("{}").unwrap();
6729
6730        let coverage_path = temp.path().join("coverage-final.json");
6731        write_single_file_istanbul_fixture(
6732            &coverage_path,
6733            &source_path,
6734            &serde_json::json!({
6735                "0": {
6736                    "name": "(anonymous_0)",
6737                    "line": 2,
6738                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6739                    "loc": {
6740                        "start": { "line": 2, "column": utf16_column(outer_body_column) },
6741                        "end": { "line": 2, "column": utf16_column(outer_body_column + 2) }
6742                    }
6743                },
6744                "1": {
6745                    "name": "render",
6746                    "line": 2,
6747                    "decl": {
6748                        "start": { "line": 2, "column": utf16_column(name_column) },
6749                        "end": { "line": 2, "column": utf16_column(name_column + "render".len()) }
6750                    },
6751                    "loc": {
6752                        "start": { "line": 2, "column": utf16_column(inner_body_column) },
6753                        "end": { "line": 2, "column": utf16_column(inner_body_column + 2) }
6754                    }
6755                }
6756            }),
6757            &serde_json::json!({ "0": 3, "1": 0 }),
6758        );
6759
6760        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6761        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6762        let coverage = load_istanbul_coverage_for_sources(
6763            &coverage_path,
6764            None,
6765            Some(temp.path()),
6766            Some(&discovered_sources),
6767            false,
6768        )
6769        .unwrap();
6770        let file_coverage = coverage.get(&canonical_source).unwrap();
6771
6772        assert_eq!(
6773            file_coverage.lookup("render", 2, u32::try_from(outer_target_column).unwrap()),
6774            Some(100.0)
6775        );
6776        assert_eq!(
6777            file_coverage.lookup("render", 2, u32::try_from(syntax_column).unwrap()),
6778            Some(0.0)
6779        );
6780    }
6781
6782    #[test]
6783    fn utf16_index_is_sparse_and_rejects_surrogate_boundaries() {
6784        let ascii_prefix = "a".repeat(4_096);
6785        let source = format!("{ascii_prefix}🍄{}", "b".repeat(4_096));
6786        let index = IstanbulSourceIndex::new(&source, std::path::Path::new("minified.js"));
6787        let line_index = &index.non_ascii_lines[&0];
6788
6789        assert_eq!(line_index.checkpoints.len(), 1);
6790        assert_eq!(
6791            index.byte_position(1, 4_096),
6792            Some(IstanbulPosition::new(1, 4_096))
6793        );
6794        assert_eq!(index.byte_position(1, 4_097), None);
6795        assert_eq!(
6796            index.byte_position(1, 4_098),
6797            Some(IstanbulPosition::new(1, 4_100))
6798        );
6799        assert_eq!(
6800            index.byte_position(1, 8_194),
6801            Some(IstanbulPosition::new(1, 8_196))
6802        );
6803    }
6804
6805    #[test]
6806    fn effective_alias_normalizes_against_its_own_unicode_line() {
6807        let temp = tempfile::TempDir::new().unwrap();
6808        let source_path = temp.path().join("src/render.ts");
6809        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6810        std::fs::write(
6811            &source_path,
6812            "/*🍄*/ const placeholder = 0;\n/*π*/ function render() {}\n",
6813        )
6814        .unwrap();
6815
6816        let coverage_path = temp.path().join("coverage-final.json");
6817        write_single_file_istanbul_fixture(
6818            &coverage_path,
6819            &source_path,
6820            &serde_json::json!({
6821                "0": {
6822                    "name": "render",
6823                    "line": 1,
6824                    "decl": { "start": { "line": 2, "column": 15 }, "end": { "line": 2, "column": 21 } },
6825                    "loc": { "start": { "line": 2, "column": 24 }, "end": { "line": 2, "column": 26 } }
6826                }
6827            }),
6828            &serde_json::json!({ "0": 1 }),
6829        );
6830
6831        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6832        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6833        let coverage = load_istanbul_coverage_for_sources(
6834            &coverage_path,
6835            None,
6836            Some(temp.path()),
6837            Some(&discovered_sources),
6838            false,
6839        )
6840        .unwrap();
6841        let function = &coverage.get(&canonical_source).unwrap().functions[0];
6842
6843        assert!(
6844            function
6845                .aliases
6846                .iter()
6847                .any(|alias| { alias.position == IstanbulPosition::new(1, 17) && alias.primary })
6848        );
6849        assert!(
6850            !function
6851                .aliases
6852                .iter()
6853                .any(|alias| alias.position == IstanbulPosition::new(1, 16))
6854        );
6855    }
6856
6857    #[test]
6858    fn stale_utf16_coordinates_are_rejected_with_trusted_source() {
6859        let temp = tempfile::TempDir::new().unwrap();
6860        let source_path = temp.path().join("src/render.ts");
6861        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6862        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6863
6864        let coverage_path = temp.path().join("coverage-final.json");
6865        write_single_file_istanbul_fixture(
6866            &coverage_path,
6867            &source_path,
6868            &serde_json::json!({
6869                "0": {
6870                    "name": "render",
6871                    "line": 1,
6872                    "decl": { "start": { "line": 1, "column": 999 }, "end": { "line": 1, "column": 22 } },
6873                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6874                }
6875            }),
6876            &serde_json::json!({ "0": 1 }),
6877        );
6878
6879        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6880        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6881        let coverage = load_istanbul_coverage_for_sources(
6882            &coverage_path,
6883            None,
6884            Some(temp.path()),
6885            Some(&discovered_sources),
6886            false,
6887        )
6888        .unwrap();
6889
6890        assert_eq!(
6891            coverage
6892                .get(&canonical_source)
6893                .unwrap()
6894                .lookup("render", 1, 7),
6895            None
6896        );
6897    }
6898
6899    #[test]
6900    fn invalid_optional_coordinates_preserve_valid_declaration_attribution() {
6901        let temp = tempfile::TempDir::new().unwrap();
6902        let source_path = temp.path().join("src/render.ts");
6903        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6904        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6905
6906        let coverage_path = temp.path().join("coverage-final.json");
6907        write_single_file_istanbul_fixture(
6908            &coverage_path,
6909            &source_path,
6910            &serde_json::json!({
6911                "0": {
6912                    "name": "render",
6913                    "line": 99,
6914                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 99, "column": 999 } },
6915                    "loc": { "start": { "line": 1, "column": 999 }, "end": { "line": 99, "column": 999 } }
6916                }
6917            }),
6918            &serde_json::json!({ "0": 1 }),
6919        );
6920
6921        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6922        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6923        let coverage = load_istanbul_coverage_for_sources(
6924            &coverage_path,
6925            None,
6926            Some(temp.path()),
6927            Some(&discovered_sources),
6928            false,
6929        )
6930        .unwrap();
6931        let file_coverage = coverage.get(&canonical_source).unwrap();
6932        let function = &file_coverage.functions[0];
6933
6934        assert_eq!(file_coverage.lookup("render", 1, 16), Some(100.0));
6935        assert!(function.body_span.is_none());
6936        assert!(function.header_span.is_none());
6937        assert!(
6938            !function
6939                .aliases
6940                .iter()
6941                .any(|alias| { alias.position.line == 99 || alias.position.col == 999 })
6942        );
6943    }
6944
6945    #[test]
6946    fn malformed_source_does_not_supply_named_function_provenance() {
6947        assert!(
6948            !IstanbulSourceIndex::new(
6949                "export function render() {}",
6950                std::path::Path::new("valid.ts"),
6951            )
6952            .named_function_starts
6953            .is_empty()
6954        );
6955        let index = IstanbulSourceIndex::new(
6956            "export function render() {} const broken = ;",
6957            std::path::Path::new("broken.ts"),
6958        );
6959
6960        assert!(index.named_function_starts.is_empty());
6961    }
6962
6963    #[test]
6964    fn javascript_with_jsx_uses_clean_jsx_provenance_parse() {
6965        let index = IstanbulSourceIndex::new(
6966            "export function render() { return <div />; }",
6967            std::path::Path::new("component.js"),
6968        );
6969
6970        assert!(!index.named_function_starts.is_empty());
6971    }
6972
6973    #[test]
6974    fn undiscovered_coverage_path_is_not_loaded_for_source_provenance() {
6975        let temp = tempfile::TempDir::new().unwrap();
6976        let source_path = temp.path().join("src/excluded.ts");
6977        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6978        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6979
6980        let coverage_path = temp.path().join("coverage-final.json");
6981        write_single_file_istanbul_fixture(
6982            &coverage_path,
6983            &source_path,
6984            &serde_json::json!({
6985                "0": {
6986                    "name": "render",
6987                    "line": 1,
6988                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 22 } },
6989                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6990                }
6991            }),
6992            &serde_json::json!({ "0": 1 }),
6993        );
6994
6995        let coverage = load_istanbul_coverage_for_sources(
6996            &coverage_path,
6997            None,
6998            Some(temp.path()),
6999            Some(&rustc_hash::FxHashSet::default()),
7000            false,
7001        )
7002        .unwrap();
7003        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7004        let function = &coverage.get(&canonical_source).unwrap().functions[0];
7005
7006        assert!(
7007            function
7008                .aliases
7009                .iter()
7010                .all(|alias| alias.position != IstanbulPosition::new(1, 7))
7011        );
7012    }
7013
7014    /// No instrumenter emits an `fnMap` identity for a private class member,
7015    /// so any candidate one reaches belongs to an enclosing function.
7016    #[test]
7017    fn private_class_member_never_takes_enclosing_coverage() {
7018        let temp = tempfile::TempDir::new().unwrap();
7019        let source_path = temp.path().join("src/vault.js");
7020        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7021        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7022
7023        let coverage_path = temp.path().join("coverage-final.json");
7024        write_single_file_istanbul_fixture(
7025            &coverage_path,
7026            &source_path,
7027            &serde_json::json!({
7028                "0": {
7029                    "name": "(anonymous_0)",
7030                    "line": 7,
7031                    "decl": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 25 } },
7032                    "loc": { "start": { "line": 7, "column": 5 }, "end": { "line": 7, "column": 20 } }
7033                }
7034            }),
7035            &serde_json::json!({ "0": 1 }),
7036        );
7037
7038        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7039        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7040        let file_coverage = coverage.get(&canonical_source).unwrap();
7041
7042        // `#wipe` sits in the arrow's header span and the arrow ran, but the
7043        // private member has no record of its own and never ran.
7044        let mut private_member = make_fn_complexity(1);
7045        private_member.name = "#wipe".to_string();
7046        private_member.is_private_member = true;
7047        private_member.line = 3;
7048        private_member.col = 9;
7049        let result = istanbul_crap_default(&[private_member], Some(file_coverage), false);
7050        assert_eq!(result.matched, 0);
7051        assert_eq!(
7052            result.per_function[0].coverage_source,
7053            fallow_output::CoverageSource::Estimated
7054        );
7055        assert_eq!(file_coverage.lookup("<arrow>", 1, 24), Some(100.0));
7056    }
7057
7058    #[test]
7059    fn quoted_hash_method_keeps_exact_istanbul_coverage() {
7060        let temp = tempfile::TempDir::new().unwrap();
7061        let source_path = temp.path().join("src/vault.js");
7062        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7063        std::fs::write(&source_path, "class Vault { '#wipe'() {} }\n").unwrap();
7064
7065        let coverage_path = temp.path().join("coverage-final.json");
7066        write_single_file_istanbul_fixture(
7067            &coverage_path,
7068            &source_path,
7069            &serde_json::json!({
7070                "0": {
7071                    "name": "#wipe",
7072                    "line": 1,
7073                    "decl": { "start": { "line": 1, "column": 14 }, "end": { "line": 1, "column": 21 } },
7074                    "loc": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 26 } }
7075                }
7076            }),
7077            &serde_json::json!({ "0": 1 }),
7078        );
7079
7080        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7081        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7082        let file_coverage = coverage.get(&canonical_source).unwrap();
7083
7084        let mut quoted_public_method = make_fn_complexity(1);
7085        quoted_public_method.name = "#wipe".to_string();
7086        quoted_public_method.line = 1;
7087        quoted_public_method.col = 14;
7088        let result = istanbul_crap_default(&[quoted_public_method], Some(file_coverage), false);
7089        assert_eq!(result.matched, 1);
7090        assert_eq!(
7091            result.per_function[0].coverage_source,
7092            fallow_output::CoverageSource::Istanbul
7093        );
7094        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
7095    }
7096
7097    /// A decorated member's `decl` opens at the decorator and its `loc` opens
7098    /// at the body brace, so the extracted position sits between them with no
7099    /// alias in reach: the decorator is more than
7100    /// `ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT` columns to the left and the body
7101    /// is more than `ALIAS_FUZZ_MAX_LINE_DRIFT` lines below. The header span
7102    /// is what identifies the member. Geometry from istanbul-lib-instrument 6
7103    /// for the source below, which is ordinary NestJS, Angular, and TypeORM
7104    /// shape rather than a corner case.
7105    #[test]
7106    fn decorated_member_matches_its_istanbul_header_span() {
7107        let temp = tempfile::TempDir::new().unwrap();
7108        let source_path = temp.path().join("src/users.controller.ts");
7109        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7110        std::fs::write(
7111            &source_path,
7112            "export class UserController {\n  @Get(\":id\")\n  async findOneWithProfile(\n    id: string,\n    include: string[]\n  ) {\n    return this.service.find(id, include);\n  }\n}\n",
7113        )
7114        .unwrap();
7115
7116        let coverage_path = temp.path().join("coverage-final.json");
7117        write_single_file_istanbul_fixture(
7118            &coverage_path,
7119            &source_path,
7120            &serde_json::json!({
7121                "0": {
7122                    "name": "(anonymous_0)",
7123                    "line": 6,
7124                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7125                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7126                }
7127            }),
7128            &serde_json::json!({ "0": 3 }),
7129        );
7130
7131        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7132        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7133        let file_coverage = coverage.get(&canonical_source).unwrap();
7134
7135        // Fallow extracts the member at the parameter paren, 3:26.
7136        assert_eq!(
7137            file_coverage.lookup("findOneWithProfile", 3, 26),
7138            Some(100.0)
7139        );
7140    }
7141
7142    /// A function written in a signature has a record of its own, and the
7143    /// signature it sits in belongs to a different function. The record wins:
7144    /// crediting the enclosing member would report the member's coverage for
7145    /// a default value that never ran. Geometry from istanbul-lib-instrument 6
7146    /// for the source below.
7147    #[test]
7148    fn established_alias_wins_over_the_signature_that_contains_it() {
7149        let temp = tempfile::TempDir::new().unwrap();
7150        let source_path = temp.path().join("src/chart.ts");
7151        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7152        std::fs::write(
7153            &source_path,
7154            "export class Chart {\n  @Watch(\"data\")\n  render(\n    rows: number[],\n    project = (row: number) => row * 2\n  ) {\n    return rows.map(project);\n  }\n}\n",
7155        )
7156        .unwrap();
7157
7158        let coverage_path = temp.path().join("coverage-final.json");
7159        write_single_file_istanbul_fixture(
7160            &coverage_path,
7161            &source_path,
7162            &serde_json::json!({
7163                "0": {
7164                    "name": "(anonymous_0)",
7165                    "line": 6,
7166                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7167                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7168                },
7169                "1": {
7170                    "name": "(anonymous_1)",
7171                    "line": 5,
7172                    "decl": { "start": { "line": 5, "column": 14 }, "end": { "line": 5, "column": 15 } },
7173                    "loc": { "start": { "line": 5, "column": 31 }, "end": { "line": 5, "column": 38 } }
7174                }
7175            }),
7176            &serde_json::json!({ "0": 3, "1": 0 }),
7177        );
7178
7179        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7180        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7181        let file_coverage = coverage.get(&canonical_source).unwrap();
7182
7183        // The default value takes its own record, not the method's 100.
7184        assert_eq!(file_coverage.lookup("<arrow>", 5, 14), Some(0.0));
7185        // The method still resolves, and not to its default value.
7186        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7187    }
7188
7189    /// A member whose signature holds a function of its own has two records a
7190    /// line or two apart, and the member's extracted position is closer to
7191    /// the inner one. Proximity would report the default value's coverage for
7192    /// the member, and the header span cannot break the tie because it
7193    /// contains both. Abstaining leaves the static estimate, which is wrong by
7194    /// a known amount rather than wrong while claiming to be measured.
7195    /// Geometry from istanbul-lib-instrument 6 for the source below.
7196    #[test]
7197    fn signature_holding_a_function_abstains_instead_of_crediting_it() {
7198        let temp = tempfile::TempDir::new().unwrap();
7199        let source_path = temp.path().join("src/users.controller.ts");
7200        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7201        std::fs::write(
7202            &source_path,
7203            "export class UserController {\n  @Get(\":id\")\n  async findOneWithProfile(\n    id: string,\n    transform = (row: string) => row.trim()\n  ) {\n    return transform(id);\n  }\n}\n",
7204        )
7205        .unwrap();
7206
7207        let coverage_path = temp.path().join("coverage-final.json");
7208        write_single_file_istanbul_fixture(
7209            &coverage_path,
7210            &source_path,
7211            &serde_json::json!({
7212                "0": {
7213                    "name": "(anonymous_0)",
7214                    "line": 6,
7215                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7216                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7217                },
7218                "1": {
7219                    "name": "(anonymous_1)",
7220                    "line": 5,
7221                    "decl": { "start": { "line": 5, "column": 16 }, "end": { "line": 5, "column": 17 } },
7222                    "loc": { "start": { "line": 5, "column": 33 }, "end": { "line": 5, "column": 43 } }
7223                }
7224            }),
7225            &serde_json::json!({ "0": 3, "1": 0 }),
7226        );
7227
7228        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7229        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7230        let file_coverage = coverage.get(&canonical_source).unwrap();
7231
7232        // The member is two lines from the default value's declaration and
7233        // ten columns off it, well inside the fallback's reach.
7234        assert_eq!(file_coverage.lookup("findOneWithProfile", 3, 26), None);
7235        // The default value itself still resolves.
7236        assert_eq!(file_coverage.lookup("<arrow>", 5, 16), Some(0.0));
7237    }
7238
7239    #[test]
7240    fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
7241        let temp = tempfile::TempDir::new().unwrap();
7242        let source_path = temp.path().join("src/aliases.ts");
7243        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7244        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7245
7246        let coverage_path = temp.path().join("coverage-final.json");
7247        write_single_file_istanbul_fixture(
7248            &coverage_path,
7249            &source_path,
7250            &serde_json::json!({
7251                "0": {
7252                    "name": "(anonymous_0)",
7253                    "line": 10,
7254                    "decl": {
7255                        "start": { "line": 10, "column": 8 },
7256                        "end": { "line": 10, "column": 9 }
7257                    },
7258                    "loc": {
7259                        "start": { "line": 12, "column": 8 },
7260                        "end": { "line": 13, "column": 1 }
7261                    }
7262                }
7263            }),
7264            &serde_json::json!({ "0": 1 }),
7265        );
7266
7267        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7268        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7269        let file_coverage = coverage.get(&canonical_source).unwrap();
7270
7271        assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
7272    }
7273
7274    /// istanbul-lib-instrument geometry for
7275    /// `export const nested = () => () => true;`: the outer arrow's `loc` is
7276    /// its expression body, which starts exactly where the inner arrow is
7277    /// declared.
7278    #[test]
7279    fn curried_arrow_one_liner_resolves_both_arrows() {
7280        let temp = tempfile::TempDir::new().unwrap();
7281        let source_path = temp.path().join("src/nested.ts");
7282        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7283        std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
7284
7285        let coverage_path = temp.path().join("coverage-final.json");
7286        write_single_file_istanbul_fixture(
7287            &coverage_path,
7288            &source_path,
7289            &serde_json::json!({
7290                "0": {
7291                    "name": "(anonymous_0)",
7292                    "line": 1,
7293                    "decl": {
7294                        "start": { "line": 1, "column": 22 },
7295                        "end": { "line": 1, "column": 23 }
7296                    },
7297                    "loc": {
7298                        "start": { "line": 1, "column": 28 },
7299                        "end": { "line": 1, "column": 38 }
7300                    }
7301                },
7302                "1": {
7303                    "name": "(anonymous_1)",
7304                    "line": 1,
7305                    "decl": {
7306                        "start": { "line": 1, "column": 28 },
7307                        "end": { "line": 1, "column": 29 }
7308                    },
7309                    "loc": {
7310                        "start": { "line": 1, "column": 34 },
7311                        "end": { "line": 1, "column": 38 }
7312                    }
7313                }
7314            }),
7315            &serde_json::json!({ "0": 1, "1": 0 }),
7316        );
7317
7318        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7319        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7320        let file_coverage = coverage.get(&canonical_source).unwrap();
7321
7322        assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
7323        assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
7324    }
7325
7326    /// A nested function body sits inside its enclosing function's `loc`, so
7327    /// counting every contained statement charged the inner body's misses to
7328    /// the outer record as well.
7329    ///
7330    /// Geometry recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7331    ///
7332    /// ```js
7333    /// function outer() {
7334    ///   return function inner(flag) {
7335    ///     if (flag) return "yes";
7336    ///     return "no";
7337    ///   };
7338    /// }
7339    ///
7340    /// module.exports = { outer };
7341    /// ```
7342    ///
7343    /// with `outer()` called once and `inner` never called. Statement 0 is
7344    /// the outer return and is hit, statements 1 to 3 are the inner body and
7345    /// are not, statement 4 is the module-scope export.
7346    #[test]
7347    fn nested_function_statements_do_not_lower_the_outer_function() {
7348        let temp = tempfile::TempDir::new().unwrap();
7349        let source_path = temp.path().join("src/nested.js");
7350        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7351        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7352
7353        let coverage_path = temp.path().join("coverage-final.json");
7354        write_single_file_istanbul_fixture_with_statements(
7355            &coverage_path,
7356            &source_path,
7357            &nested_return_fn_map(),
7358            &serde_json::json!({ "0": 1, "1": 0 }),
7359            &nested_return_statement_map(),
7360            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 1 }),
7361        );
7362
7363        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7364        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7365        let file_coverage = coverage.get(&canonical_source).unwrap();
7366
7367        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7368        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7369    }
7370
7371    /// A statement outside every `fnMap` record belongs to no function. The
7372    /// module-scope export in the fixture above is hit, and flipping it to
7373    /// unhit must not move either function.
7374    #[test]
7375    fn module_scope_statements_belong_to_no_function() {
7376        let temp = tempfile::TempDir::new().unwrap();
7377        let source_path = temp.path().join("src/nested.js");
7378        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7379        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7380
7381        let coverage_path = temp.path().join("coverage-final.json");
7382        write_single_file_istanbul_fixture_with_statements(
7383            &coverage_path,
7384            &source_path,
7385            &nested_return_fn_map(),
7386            &serde_json::json!({ "0": 1, "1": 0 }),
7387            &nested_return_statement_map(),
7388            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 0 }),
7389        );
7390
7391        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7392        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7393        let file_coverage = coverage.get(&canonical_source).unwrap();
7394
7395        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7396        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7397    }
7398
7399    /// Ownership can leave a record with no statements of its own, and that
7400    /// record still has to fall back to the function hit count. Geometry
7401    /// recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7402    ///
7403    /// ```js
7404    /// function outer() {
7405    ///   function inner(flag) {
7406    ///     if (flag) return "yes";
7407    ///     return "no";
7408    ///   }
7409    /// }
7410    ///
7411    /// module.exports = { outer };
7412    /// ```
7413    ///
7414    /// with `outer()` called once. A hoisted declaration is not a statement,
7415    /// so every statement inside `outer` belongs to `inner`.
7416    #[test]
7417    fn an_outer_function_with_only_a_nested_body_falls_back_to_the_hit_count() {
7418        let temp = tempfile::TempDir::new().unwrap();
7419        let source_path = temp.path().join("src/decl-nested.js");
7420        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7421        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7422
7423        let coverage_path = temp.path().join("coverage-final.json");
7424        write_single_file_istanbul_fixture_with_statements(
7425            &coverage_path,
7426            &source_path,
7427            &serde_json::json!({
7428                "0": {
7429                    "name": "outer",
7430                    "line": 1,
7431                    "decl": {
7432                        "start": { "line": 1, "column": 9 },
7433                        "end": { "line": 1, "column": 14 }
7434                    },
7435                    "loc": {
7436                        "start": { "line": 1, "column": 17 },
7437                        "end": { "line": 6, "column": 1 }
7438                    }
7439                },
7440                "1": {
7441                    "name": "inner",
7442                    "line": 2,
7443                    "decl": {
7444                        "start": { "line": 2, "column": 11 },
7445                        "end": { "line": 2, "column": 16 }
7446                    },
7447                    "loc": {
7448                        "start": { "line": 2, "column": 23 },
7449                        "end": { "line": 5, "column": 3 }
7450                    }
7451                }
7452            }),
7453            &serde_json::json!({ "0": 1, "1": 0 }),
7454            &serde_json::json!({
7455                "0": {
7456                    "start": { "line": 3, "column": 4 },
7457                    "end": { "line": 3, "column": 27 }
7458                },
7459                "1": {
7460                    "start": { "line": 3, "column": 14 },
7461                    "end": { "line": 3, "column": 27 }
7462                },
7463                "2": {
7464                    "start": { "line": 4, "column": 4 },
7465                    "end": { "line": 4, "column": 16 }
7466                },
7467                "3": {
7468                    "start": { "line": 8, "column": 0 },
7469                    "end": { "line": 8, "column": 27 }
7470                }
7471            }),
7472            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1 }),
7473        );
7474
7475        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7476        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7477        let file_coverage = coverage.get(&canonical_source).unwrap();
7478
7479        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7480        assert_eq!(file_coverage.lookup("inner", 2, 11), Some(0.0));
7481    }
7482
7483    /// Two functions at module scope never contain each other, so ownership
7484    /// must leave them exactly where they were. Geometry recorded verbatim
7485    /// from istanbul-lib-instrument 6.0.3 for the same source with `inner`
7486    /// hoisted out of `outer`.
7487    #[test]
7488    fn sibling_functions_keep_independent_statement_coverage() {
7489        let temp = tempfile::TempDir::new().unwrap();
7490        let source_path = temp.path().join("src/sibling.js");
7491        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7492        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7493
7494        let coverage_path = temp.path().join("coverage-final.json");
7495        write_single_file_istanbul_fixture_with_statements(
7496            &coverage_path,
7497            &source_path,
7498            &serde_json::json!({
7499                "0": {
7500                    "name": "inner",
7501                    "line": 1,
7502                    "decl": {
7503                        "start": { "line": 1, "column": 9 },
7504                        "end": { "line": 1, "column": 14 }
7505                    },
7506                    "loc": {
7507                        "start": { "line": 1, "column": 21 },
7508                        "end": { "line": 4, "column": 1 }
7509                    }
7510                },
7511                "1": {
7512                    "name": "outer",
7513                    "line": 6,
7514                    "decl": {
7515                        "start": { "line": 6, "column": 9 },
7516                        "end": { "line": 6, "column": 14 }
7517                    },
7518                    "loc": {
7519                        "start": { "line": 6, "column": 17 },
7520                        "end": { "line": 8, "column": 1 }
7521                    }
7522                }
7523            }),
7524            &serde_json::json!({ "0": 0, "1": 1 }),
7525            &serde_json::json!({
7526                "0": {
7527                    "start": { "line": 2, "column": 2 },
7528                    "end": { "line": 2, "column": 25 }
7529                },
7530                "1": {
7531                    "start": { "line": 2, "column": 12 },
7532                    "end": { "line": 2, "column": 25 }
7533                },
7534                "2": {
7535                    "start": { "line": 3, "column": 2 },
7536                    "end": { "line": 3, "column": 14 }
7537                },
7538                "3": {
7539                    "start": { "line": 7, "column": 2 },
7540                    "end": { "line": 7, "column": 15 }
7541                },
7542                "4": {
7543                    "start": { "line": 10, "column": 0 },
7544                    "end": { "line": 10, "column": 27 }
7545                }
7546            }),
7547            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1, "4": 1 }),
7548        );
7549
7550        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7551        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7552        let file_coverage = coverage.get(&canonical_source).unwrap();
7553
7554        assert_eq!(file_coverage.lookup("outer", 6, 9), Some(100.0));
7555        assert_eq!(file_coverage.lookup("inner", 1, 9), Some(0.0));
7556    }
7557
7558    /// Curried arrows share their closing position, so the inner body is
7559    /// distinguished only by its later start. Geometry recorded verbatim from
7560    /// istanbul-lib-instrument 6.0.3 for `const add = (a) => (b) => a + b;`
7561    /// with `add(1)` called once, so the outer arrow ran and the inner did
7562    /// not. Statement 1 is the outer expression body, statement 2 the inner.
7563    #[test]
7564    fn curried_arrow_statements_belong_to_the_innermost_arrow() {
7565        let temp = tempfile::TempDir::new().unwrap();
7566        let source_path = temp.path().join("src/curried.js");
7567        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7568        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7569
7570        let coverage_path = temp.path().join("coverage-final.json");
7571        write_single_file_istanbul_fixture_with_statements(
7572            &coverage_path,
7573            &source_path,
7574            &serde_json::json!({
7575                "0": {
7576                    "name": "(anonymous_0)",
7577                    "line": 1,
7578                    "decl": {
7579                        "start": { "line": 1, "column": 12 },
7580                        "end": { "line": 1, "column": 13 }
7581                    },
7582                    "loc": {
7583                        "start": { "line": 1, "column": 19 },
7584                        "end": { "line": 1, "column": 31 }
7585                    }
7586                },
7587                "1": {
7588                    "name": "(anonymous_1)",
7589                    "line": 1,
7590                    "decl": {
7591                        "start": { "line": 1, "column": 19 },
7592                        "end": { "line": 1, "column": 20 }
7593                    },
7594                    "loc": {
7595                        "start": { "line": 1, "column": 26 },
7596                        "end": { "line": 1, "column": 31 }
7597                    }
7598                }
7599            }),
7600            &serde_json::json!({ "0": 1, "1": 0 }),
7601            &serde_json::json!({
7602                "0": {
7603                    "start": { "line": 1, "column": 12 },
7604                    "end": { "line": 1, "column": 31 }
7605                },
7606                "1": {
7607                    "start": { "line": 1, "column": 19 },
7608                    "end": { "line": 1, "column": 31 }
7609                },
7610                "2": {
7611                    "start": { "line": 1, "column": 26 },
7612                    "end": { "line": 1, "column": 31 }
7613                },
7614                "3": {
7615                    "start": { "line": 3, "column": 0 },
7616                    "end": { "line": 3, "column": 25 }
7617                }
7618            }),
7619            &serde_json::json!({ "0": 1, "1": 1, "2": 0, "3": 1 }),
7620        );
7621
7622        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7623        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7624        let file_coverage = coverage.get(&canonical_source).unwrap();
7625
7626        assert_eq!(file_coverage.lookup("add", 1, 12), Some(100.0));
7627        assert_eq!(file_coverage.lookup("<arrow>", 1, 19), Some(0.0));
7628    }
7629
7630    /// The `fnMap` and `statementMap` istanbul-lib-instrument 6.0.3 records
7631    /// for the nested-return source, shared by the two tests that pin
7632    /// statement ownership against it.
7633    fn nested_return_fn_map() -> serde_json::Value {
7634        serde_json::json!({
7635            "0": {
7636                "name": "outer",
7637                "line": 1,
7638                "decl": {
7639                    "start": { "line": 1, "column": 9 },
7640                    "end": { "line": 1, "column": 14 }
7641                },
7642                "loc": {
7643                    "start": { "line": 1, "column": 17 },
7644                    "end": { "line": 6, "column": 1 }
7645                }
7646            },
7647            "1": {
7648                "name": "inner",
7649                "line": 2,
7650                "decl": {
7651                    "start": { "line": 2, "column": 18 },
7652                    "end": { "line": 2, "column": 23 }
7653                },
7654                "loc": {
7655                    "start": { "line": 2, "column": 30 },
7656                    "end": { "line": 5, "column": 3 }
7657                }
7658            }
7659        })
7660    }
7661
7662    fn nested_return_statement_map() -> serde_json::Value {
7663        serde_json::json!({
7664            "0": {
7665                "start": { "line": 2, "column": 2 },
7666                "end": { "line": 5, "column": 4 }
7667            },
7668            "1": {
7669                "start": { "line": 3, "column": 4 },
7670                "end": { "line": 3, "column": 27 }
7671            },
7672            "2": {
7673                "start": { "line": 3, "column": 14 },
7674                "end": { "line": 3, "column": 27 }
7675            },
7676            "3": {
7677                "start": { "line": 4, "column": 4 },
7678                "end": { "line": 4, "column": 16 }
7679            },
7680            "4": {
7681                "start": { "line": 8, "column": 0 },
7682                "end": { "line": 8, "column": 27 }
7683            }
7684        })
7685    }
7686
7687    /// istanbul-lib-instrument geometry for a multi-line higher-order
7688    /// component. The producer's `line` is the body start line, so the outer
7689    /// record also carries an effective alias on line 2.
7690    #[test]
7691    fn curried_arrow_multiline_hoc_resolves_both_arrows() {
7692        let temp = tempfile::TempDir::new().unwrap();
7693        let source_path = temp.path().join("src/with-auth.tsx");
7694        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7695        std::fs::write(
7696            &source_path,
7697            "export const withAuth = (Component) =>\n  (props) => {\n    return Component(props);\n  };\n",
7698        )
7699        .unwrap();
7700
7701        let coverage_path = temp.path().join("coverage-final.json");
7702        write_single_file_istanbul_fixture(
7703            &coverage_path,
7704            &source_path,
7705            &serde_json::json!({
7706                "0": {
7707                    "name": "(anonymous_0)",
7708                    "line": 2,
7709                    "decl": {
7710                        "start": { "line": 1, "column": 24 },
7711                        "end": { "line": 1, "column": 25 }
7712                    },
7713                    "loc": {
7714                        "start": { "line": 2, "column": 2 },
7715                        "end": { "line": 4, "column": 3 }
7716                    }
7717                },
7718                "1": {
7719                    "name": "(anonymous_1)",
7720                    "line": 2,
7721                    "decl": {
7722                        "start": { "line": 2, "column": 2 },
7723                        "end": { "line": 2, "column": 3 }
7724                    },
7725                    "loc": {
7726                        "start": { "line": 2, "column": 13 },
7727                        "end": { "line": 4, "column": 3 }
7728                    }
7729                }
7730            }),
7731            &serde_json::json!({ "0": 1, "1": 0 }),
7732        );
7733
7734        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7735        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7736        let file_coverage = coverage.get(&canonical_source).unwrap();
7737
7738        assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
7739        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
7740    }
7741
7742    /// istanbul-lib-instrument geometry for a depth-3 redux middleware chain.
7743    /// Every non-first arrow is declared where the previous body starts.
7744    #[test]
7745    fn curried_arrow_depth_three_chain_resolves_every_arrow() {
7746        let temp = tempfile::TempDir::new().unwrap();
7747        let source_path = temp.path().join("src/logger.ts");
7748        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7749        std::fs::write(
7750            &source_path,
7751            "export const logger = (store) => (next) => (action) => {\n  return next(action);\n};\n",
7752        )
7753        .unwrap();
7754
7755        let coverage_path = temp.path().join("coverage-final.json");
7756        write_single_file_istanbul_fixture(
7757            &coverage_path,
7758            &source_path,
7759            &serde_json::json!({
7760                "0": {
7761                    "name": "(anonymous_0)",
7762                    "line": 1,
7763                    "decl": {
7764                        "start": { "line": 1, "column": 22 },
7765                        "end": { "line": 1, "column": 23 }
7766                    },
7767                    "loc": {
7768                        "start": { "line": 1, "column": 33 },
7769                        "end": { "line": 3, "column": 1 }
7770                    }
7771                },
7772                "1": {
7773                    "name": "(anonymous_1)",
7774                    "line": 1,
7775                    "decl": {
7776                        "start": { "line": 1, "column": 33 },
7777                        "end": { "line": 1, "column": 34 }
7778                    },
7779                    "loc": {
7780                        "start": { "line": 1, "column": 43 },
7781                        "end": { "line": 3, "column": 1 }
7782                    }
7783                },
7784                "2": {
7785                    "name": "(anonymous_2)",
7786                    "line": 1,
7787                    "decl": {
7788                        "start": { "line": 1, "column": 43 },
7789                        "end": { "line": 1, "column": 44 }
7790                    },
7791                    "loc": {
7792                        "start": { "line": 1, "column": 55 },
7793                        "end": { "line": 3, "column": 1 }
7794                    }
7795                }
7796            }),
7797            &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
7798        );
7799
7800        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7801        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7802        let file_coverage = coverage.get(&canonical_source).unwrap();
7803
7804        assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
7805        assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
7806        assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
7807    }
7808
7809    /// istanbul-lib-instrument geometry for a curried class-property arrow.
7810    #[test]
7811    fn curried_class_property_arrow_resolves_both_arrows() {
7812        let temp = tempfile::TempDir::new().unwrap();
7813        let source_path = temp.path().join("src/store.ts");
7814        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7815        std::fs::write(
7816            &source_path,
7817            "export class Store {\n  handle = (event) => (payload) => {\n    return payload;\n  };\n}\n",
7818        )
7819        .unwrap();
7820
7821        let coverage_path = temp.path().join("coverage-final.json");
7822        write_single_file_istanbul_fixture(
7823            &coverage_path,
7824            &source_path,
7825            &serde_json::json!({
7826                "0": {
7827                    "name": "(anonymous_0)",
7828                    "line": 2,
7829                    "decl": {
7830                        "start": { "line": 2, "column": 11 },
7831                        "end": { "line": 2, "column": 12 }
7832                    },
7833                    "loc": {
7834                        "start": { "line": 2, "column": 22 },
7835                        "end": { "line": 4, "column": 3 }
7836                    }
7837                },
7838                "1": {
7839                    "name": "(anonymous_1)",
7840                    "line": 2,
7841                    "decl": {
7842                        "start": { "line": 2, "column": 22 },
7843                        "end": { "line": 2, "column": 23 }
7844                    },
7845                    "loc": {
7846                        "start": { "line": 2, "column": 35 },
7847                        "end": { "line": 4, "column": 3 }
7848                    }
7849                }
7850            }),
7851            &serde_json::json!({ "0": 1, "1": 0 }),
7852        );
7853
7854        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7855        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7856        let file_coverage = coverage.get(&canonical_source).unwrap();
7857
7858        assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
7859        assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
7860    }
7861
7862    /// istanbul-lib-instrument geometry for two sibling arrows in an object
7863    /// literal. A target one line away from both, at the shared column, ties
7864    /// on distance and lies in neither body, so the lookup abstains.
7865    #[test]
7866    fn anonymous_sibling_tie_outside_every_body_abstains() {
7867        let temp = tempfile::TempDir::new().unwrap();
7868        let source_path = temp.path().join("src/handlers.ts");
7869        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7870        std::fs::write(
7871            &source_path,
7872            "export const handlers = {\n  a: () => true,\n\n  b: () => false,\n};\n",
7873        )
7874        .unwrap();
7875
7876        let coverage_path = temp.path().join("coverage-final.json");
7877        write_single_file_istanbul_fixture(
7878            &coverage_path,
7879            &source_path,
7880            &serde_json::json!({
7881                "0": {
7882                    "name": "(anonymous_0)",
7883                    "line": 2,
7884                    "decl": {
7885                        "start": { "line": 2, "column": 5 },
7886                        "end": { "line": 2, "column": 6 }
7887                    },
7888                    "loc": {
7889                        "start": { "line": 2, "column": 11 },
7890                        "end": { "line": 2, "column": 15 }
7891                    }
7892                },
7893                "1": {
7894                    "name": "(anonymous_1)",
7895                    "line": 4,
7896                    "decl": {
7897                        "start": { "line": 4, "column": 5 },
7898                        "end": { "line": 4, "column": 6 }
7899                    },
7900                    "loc": {
7901                        "start": { "line": 4, "column": 11 },
7902                        "end": { "line": 4, "column": 16 }
7903                    }
7904                }
7905            }),
7906            &serde_json::json!({ "0": 1, "1": 0 }),
7907        );
7908
7909        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7910        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7911        let file_coverage = coverage.get(&canonical_source).unwrap();
7912
7913        assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
7914        assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
7915        assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
7916    }
7917
7918    /// istanbul-lib-instrument geometry for a function expression whose block
7919    /// body wraps an arrow, with the closing braces on a second line. A target
7920    /// on that line, equidistant from the outer `{` and the inner declaration,
7921    /// ties on distance and lies inside both bodies; the strictly innermost
7922    /// body wins.
7923    #[test]
7924    fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
7925        let temp = tempfile::TempDir::new().unwrap();
7926        let source_path = temp.path().join("src/nested.ts");
7927        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7928        std::fs::write(
7929            &source_path,
7930            "export const o = function () { const f = () => { return 1;\n                                       }; return f; };\n",
7931        )
7932        .unwrap();
7933
7934        let coverage_path = temp.path().join("coverage-final.json");
7935        write_single_file_istanbul_fixture(
7936            &coverage_path,
7937            &source_path,
7938            &serde_json::json!({
7939                "0": {
7940                    "name": "(anonymous_0)",
7941                    "line": 1,
7942                    "decl": {
7943                        "start": { "line": 1, "column": 17 },
7944                        "end": { "line": 1, "column": 18 }
7945                    },
7946                    "loc": {
7947                        "start": { "line": 1, "column": 29 },
7948                        "end": { "line": 2, "column": 53 }
7949                    }
7950                },
7951                "1": {
7952                    "name": "(anonymous_1)",
7953                    "line": 1,
7954                    "decl": {
7955                        "start": { "line": 1, "column": 41 },
7956                        "end": { "line": 1, "column": 42 }
7957                    },
7958                    "loc": {
7959                        "start": { "line": 1, "column": 47 },
7960                        "end": { "line": 2, "column": 40 }
7961                    }
7962                }
7963            }),
7964            &serde_json::json!({ "0": 1, "1": 0 }),
7965        );
7966
7967        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7968        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7969        let file_coverage = coverage.get(&canonical_source).unwrap();
7970
7971        assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
7972    }
7973
7974    /// Defensive: no producer emits partially overlapping bodies, but a tie
7975    /// between two containing spans that are not nested must not pick either.
7976    #[test]
7977    fn anonymous_tie_rejects_incomparable_containing_spans() {
7978        let file_coverage = IstanbulFileCoverage::new(
7979            vec![
7980                IstanbulFunctionCoverage {
7981                    name: "(anonymous_0)".to_string(),
7982                    coverage_pct: 100.0,
7983                    aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
7984                    decl_start: IstanbulPosition::new(10, 8),
7985                    header_holds_other_fn: false,
7986                    header_span: None,
7987                    body_span: Some(body_span((10, 14), (12, 30))),
7988                },
7989                IstanbulFunctionCoverage {
7990                    name: "(anonymous_1)".to_string(),
7991                    coverage_pct: 0.0,
7992                    aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
7993                    decl_start: IstanbulPosition::new(10, 20),
7994                    header_holds_other_fn: false,
7995                    header_span: None,
7996                    body_span: Some(body_span((11, 0), (14, 0))),
7997                },
7998            ],
7999            false,
8000        );
8001
8002        assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
8003    }
8004
8005    /// Two records whose primary aliases coincide (a multi-line parameter
8006    /// list whose body-start line and declaration column produce the same
8007    /// effective position as the inner arrow's declaration) stay ambiguous
8008    /// even though their bodies nest.
8009    #[test]
8010    fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
8011        let file_coverage = IstanbulFileCoverage::new(
8012            vec![
8013                IstanbulFunctionCoverage {
8014                    name: "(anonymous_0)".to_string(),
8015                    coverage_pct: 100.0,
8016                    aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
8017                    decl_start: IstanbulPosition::new(4, 11),
8018                    header_holds_other_fn: false,
8019                    header_span: None,
8020                    body_span: Some(body_span((4, 11), (4, 23))),
8021                },
8022                IstanbulFunctionCoverage {
8023                    name: "(anonymous_1)".to_string(),
8024                    coverage_pct: 0.0,
8025                    aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
8026                    decl_start: IstanbulPosition::new(4, 11),
8027                    header_holds_other_fn: false,
8028                    header_span: None,
8029                    body_span: Some(body_span((4, 18), (4, 23))),
8030                },
8031            ],
8032            false,
8033        );
8034
8035        assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
8036        assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
8037    }
8038
8039    #[test]
8040    fn colliding_anonymous_alias_uses_unique_safe_header_span() {
8041        let file_coverage = IstanbulFileCoverage::new(
8042            vec![
8043                IstanbulFunctionCoverage {
8044                    name: "(anonymous_0)".to_string(),
8045                    coverage_pct: 100.0,
8046                    aliases: vec![primary_alias(1, 0), secondary_alias(3, 4)],
8047                    decl_start: IstanbulPosition::new(1, 0),
8048                    header_holds_other_fn: false,
8049                    header_span: Some(body_span((1, 0), (5, 0))),
8050                    body_span: Some(body_span((5, 0), (8, 0))),
8051                },
8052                IstanbulFunctionCoverage {
8053                    name: "(anonymous_1)".to_string(),
8054                    coverage_pct: 0.0,
8055                    aliases: vec![primary_alias(10, 0), secondary_alias(3, 4)],
8056                    decl_start: IstanbulPosition::new(10, 0),
8057                    header_holds_other_fn: false,
8058                    header_span: None,
8059                    body_span: Some(body_span((10, 0), (12, 0))),
8060                },
8061            ],
8062            false,
8063        );
8064
8065        assert_eq!(file_coverage.lookup("<arrow>", 3, 4), Some(100.0));
8066    }
8067
8068    /// A secondary alias that collides with another record's secondary alias
8069    /// is dropped from both, and the shared position remains ambiguous.
8070    #[test]
8071    fn colliding_secondary_aliases_abstain_at_shared_position() {
8072        let file_coverage = IstanbulFileCoverage::new(
8073            vec![
8074                IstanbulFunctionCoverage {
8075                    name: "(anonymous_0)".to_string(),
8076                    coverage_pct: 100.0,
8077                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8078                    decl_start: IstanbulPosition::new(10, 0),
8079                    header_holds_other_fn: false,
8080                    header_span: None,
8081                    body_span: Some(body_span((12, 4), (20, 0))),
8082                },
8083                IstanbulFunctionCoverage {
8084                    name: "(anonymous_1)".to_string(),
8085                    coverage_pct: 0.0,
8086                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8087                    decl_start: IstanbulPosition::new(11, 0),
8088                    header_holds_other_fn: false,
8089                    header_span: None,
8090                    body_span: Some(body_span((12, 4), (18, 0))),
8091                },
8092            ],
8093            false,
8094        );
8095
8096        assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
8097        assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
8098        assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
8099    }
8100
8101    #[test]
8102    fn colliding_named_secondary_aliases_abstain_at_shared_position() {
8103        let file_coverage = IstanbulFileCoverage::new(
8104            vec![
8105                IstanbulFunctionCoverage {
8106                    name: "handler".to_string(),
8107                    coverage_pct: 100.0,
8108                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8109                    decl_start: IstanbulPosition::new(10, 0),
8110                    header_holds_other_fn: false,
8111                    header_span: None,
8112                    body_span: Some(body_span((12, 4), (20, 0))),
8113                },
8114                IstanbulFunctionCoverage {
8115                    name: "handler".to_string(),
8116                    coverage_pct: 0.0,
8117                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8118                    decl_start: IstanbulPosition::new(11, 0),
8119                    header_holds_other_fn: false,
8120                    header_span: None,
8121                    body_span: Some(body_span((12, 4), (18, 0))),
8122                },
8123            ],
8124            false,
8125        );
8126
8127        assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
8128        assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
8129        assert!(file_coverage.lookup("handler", 12, 4).is_none());
8130    }
8131
8132    #[test]
8133    fn invalid_body_location_does_not_create_an_alias() {
8134        let temp = tempfile::TempDir::new().unwrap();
8135        let source_path = temp.path().join("src/invalid-location.ts");
8136        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8137        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
8138
8139        let coverage_path = temp.path().join("coverage-final.json");
8140        write_single_file_istanbul_fixture(
8141            &coverage_path,
8142            &source_path,
8143            &serde_json::json!({
8144                "0": {
8145                    "name": "(anonymous_0)",
8146                    "line": 8,
8147                    "decl": {
8148                        "start": { "line": 8, "column": 14 },
8149                        "end": { "line": 8, "column": 25 }
8150                    },
8151                    "loc": {
8152                        "start": { "line": 22, "column": 1 },
8153                        "end": { "line": 20, "column": 6 }
8154                    }
8155                }
8156            }),
8157            &serde_json::json!({ "0": 1 }),
8158        );
8159
8160        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8161        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8162        let file_coverage = coverage.get(&canonical_source).unwrap();
8163
8164        assert!(file_coverage.lookup("handler", 22, 1).is_none());
8165    }
8166
8167    #[test]
8168    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
8169        let temp = tempfile::TempDir::new().unwrap();
8170        let source_path = temp.path().join("src/actor.ts");
8171        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8172        std::fs::write(
8173            &source_path,
8174            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
8175        )
8176        .unwrap();
8177
8178        let coverage_path = temp.path().join("coverage-final.json");
8179        write_single_file_istanbul_fixture(
8180            &coverage_path,
8181            &source_path,
8182            &serde_json::json!({
8183                "0": {
8184                    "name": "(anonymous_0)",
8185                    "line": 4,
8186                    "decl": {
8187                        "start": { "line": 1, "column": 28 },
8188                        "end": { "line": 4, "column": 26 }
8189                    },
8190                    "loc": {
8191                        "start": { "line": 4, "column": 27 },
8192                        "end": { "line": 6, "column": 1 }
8193                    }
8194                }
8195            }),
8196            &serde_json::json!({
8197                "0": 642
8198            }),
8199        );
8200
8201        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8202        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8203        let file_coverage = coverage.get(&canonical_source).unwrap();
8204
8205        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
8206    }
8207
8208    #[test]
8209    fn istanbul_lookup_exact_match() {
8210        let mut functions = rustc_hash::FxHashMap::default();
8211        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8212        let fc = test_istanbul_file_coverage(functions, false);
8213        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
8214    }
8215
8216    #[test]
8217    fn istanbul_lookup_fuzzy_match_within_offset() {
8218        let mut functions = rustc_hash::FxHashMap::default();
8219        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8220        let fc = test_istanbul_file_coverage(functions, false);
8221        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8222        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8223    }
8224
8225    #[test]
8226    fn istanbul_lookup_fuzzy_match_outside_offset() {
8227        let mut functions = rustc_hash::FxHashMap::default();
8228        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8229        let fc = test_istanbul_file_coverage(functions, false);
8230        assert!(fc.lookup("handleClick", 13, 0).is_none());
8231    }
8232
8233    #[test]
8234    fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
8235        let mut functions = rustc_hash::FxHashMap::default();
8236        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8237        let fc = test_istanbul_file_coverage(functions, true);
8238        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8239    }
8240
8241    #[test]
8242    fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
8243        let mut functions = rustc_hash::FxHashMap::default();
8244        functions.insert(("handleClick".to_string(), 29, 16), 72.0);
8245        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8246        let fc = test_istanbul_file_coverage(functions, true);
8247        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8248    }
8249
8250    #[test]
8251    fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
8252        let mut functions = rustc_hash::FxHashMap::default();
8253        functions.insert(("render".to_string(), 29, 0), 72.0);
8254        functions.insert(("render".to_string(), 80, 0), 10.0);
8255        let fc = test_istanbul_file_coverage(functions, true);
8256        assert!(fc.lookup("render", 10, 0).is_none());
8257    }
8258
8259    #[test]
8260    fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
8261        let mut functions = rustc_hash::FxHashMap::default();
8262        functions.insert(("render".to_string(), 11, 0), 72.0);
8263        functions.insert(("render".to_string(), 80, 0), 10.0);
8264        let fc = test_istanbul_file_coverage(functions, true);
8265        assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8266    }
8267
8268    #[test]
8269    fn istanbul_lookup_name_mismatch() {
8270        let mut functions = rustc_hash::FxHashMap::default();
8271        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8272        let fc = test_istanbul_file_coverage(functions, false);
8273        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
8274    }
8275
8276    #[test]
8277    fn istanbul_lookup_empty() {
8278        let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
8279        assert!(fc.lookup("anything", 1, 0).is_none());
8280    }
8281
8282    #[test]
8283    fn istanbul_lookup_fuzzy_picks_closest() {
8284        let mut functions = rustc_hash::FxHashMap::default();
8285        functions.insert(("render".to_string(), 8, 0), 60.0);
8286        functions.insert(("render".to_string(), 12, 0), 90.0);
8287        let fc = test_istanbul_file_coverage(functions, false);
8288        let result = fc.lookup("render", 10, 0);
8289        assert!(result.is_some());
8290        let pct = result.unwrap();
8291        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
8292    }
8293
8294    #[test]
8295    fn istanbul_lookup_anonymous_fallback_single_candidate() {
8296        let mut functions = rustc_hash::FxHashMap::default();
8297        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8298        let fc = test_istanbul_file_coverage(functions, false);
8299        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8300        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8301    }
8302
8303    #[test]
8304    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
8305        let mut functions = rustc_hash::FxHashMap::default();
8306        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
8307        let fc = test_istanbul_file_coverage(functions, false);
8308
8309        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
8310    }
8311
8312    #[test]
8313    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
8314        let mut functions = rustc_hash::FxHashMap::default();
8315        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8316        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8317        let fc = test_istanbul_file_coverage(functions, false);
8318        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8319    }
8320
8321    #[test]
8322    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
8323        let mut functions = rustc_hash::FxHashMap::default();
8324        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
8325        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
8326        let fc = test_istanbul_file_coverage(functions, false);
8327        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
8328        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
8329    }
8330
8331    #[test]
8332    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
8333        let mut functions = rustc_hash::FxHashMap::default();
8334        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
8335        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8336        let fc = test_istanbul_file_coverage(functions, false);
8337        assert!(fc.lookup("myHandler", 28, 0).is_none());
8338    }
8339
8340    #[test]
8341    fn istanbul_lookup_anonymous_fallback_outside_offset() {
8342        let mut functions = rustc_hash::FxHashMap::default();
8343        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8344        let fc = test_istanbul_file_coverage(functions, false);
8345        assert!(fc.lookup("myHandler", 31, 0).is_none());
8346    }
8347
8348    #[test]
8349    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
8350        let mut functions = rustc_hash::FxHashMap::default();
8351        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
8352        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
8353        let fc = test_istanbul_file_coverage(functions, false);
8354        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
8355    }
8356
8357    #[test]
8358    fn build_test_refs_empty() {
8359        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
8360        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
8361        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
8362        assert!(refs.is_empty());
8363    }
8364
8365    #[test]
8366    fn istanbul_crap_empty_complexity() {
8367        let result = istanbul_crap_default(&[], None, false);
8368        assert!((result.max_crap).abs() < f64::EPSILON);
8369        assert_eq!(result.signals.above, 0);
8370        assert_eq!(result.matched, 0);
8371        assert_eq!(result.total, 0);
8372    }
8373
8374    #[test]
8375    fn istanbul_crap_match_statistics() {
8376        let funcs = vec![make_fn_complexity(5), {
8377            let mut f = make_fn_complexity(3);
8378            f.name = "other_fn".into();
8379            f.line = 10;
8380            f
8381        }];
8382        let mut functions = rustc_hash::FxHashMap::default();
8383        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
8384        let file_cov = test_istanbul_file_coverage(functions, false);
8385        let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
8386        assert_eq!(result.matched, 1);
8387        assert_eq!(result.total, 2);
8388    }
8389
8390    #[test]
8391    fn estimated_crap_multiple_functions_mixed_coverage() {
8392        let funcs = vec![
8393            make_fn_complexity(10), // name "test_fn" line 1
8394            {
8395                let mut f = make_fn_complexity(3);
8396                f.name = "helper".into();
8397                f.line = 20;
8398                f
8399            },
8400        ];
8401        let mut refs = rustc_hash::FxHashSet::default();
8402        refs.insert("test_fn".to_string());
8403        let result = estimated_crap_default(
8404            &funcs,
8405            &refs,
8406            true,
8407            fallow_output::CoverageSource::Estimated,
8408        );
8409        let (max, above) = (result.max_crap, result.signals.above);
8410        assert!(max > 10.0);
8411        assert_eq!(above, 0);
8412    }
8413}