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.panicked || !parsed.errors.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                },
4980            ),
4981        );
4982
4983        let output = crate::results::DeadCodeAnalysisArtifacts {
4984            results,
4985            timings: None,
4986            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4987            modules: None,
4988            files: None,
4989            script_used_packages: rustc_hash::FxHashSet::default(),
4990            trace_provenance: crate::trace::TraceProvenance::default(),
4991            file_hashes: rustc_hash::FxHashMap::default(),
4992        };
4993
4994        let result = compute_file_scores_default(
4995            &modules,
4996            &file_paths,
4997            None,
4998            output,
4999            None,
5000            std::path::Path::new("/project"),
5001        )
5002        .unwrap();
5003        assert_eq!(result.analysis_counts.total_exports, 2);
5004        assert_eq!(result.analysis_counts.dead_exports, 2);
5005        assert_eq!(result.analysis_counts.unused_deps, 1);
5006    }
5007
5008    /// Regression: total_exports must count graph modules, not extraction modules.
5009    #[test]
5010    #[expect(
5011        clippy::too_many_lines,
5012        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5013    )]
5014    fn total_exports_counts_graph_modules_not_extraction_modules() {
5015        let path_a = std::path::PathBuf::from("/src/a.ts");
5016        let files = vec![crate::discover::DiscoveredFile {
5017            id: crate::discover::FileId(0),
5018            path: path_a.clone(),
5019            size_bytes: 100,
5020        }];
5021
5022        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5023            file_id: crate::discover::FileId(0),
5024            path: path_a.clone(),
5025            exports: vec![
5026                fallow_types::extract::ExportInfo {
5027                    name: crate::source::ExportName::Named("foo".into()),
5028                    local_name: None,
5029                    is_type_only: false,
5030                    visibility: crate::source::VisibilityTag::None,
5031                    expected_unused_reason: None,
5032                    span: oxc_span::Span::empty(0),
5033                    members: vec![],
5034                    is_side_effect_used: false,
5035                    super_class: None,
5036                    deprecated: false,
5037                    deprecated_reason: None,
5038                },
5039                fallow_types::extract::ExportInfo {
5040                    name: crate::source::ExportName::Named("bar".into()),
5041                    local_name: None,
5042                    is_type_only: false,
5043                    visibility: crate::source::VisibilityTag::None,
5044                    expected_unused_reason: None,
5045                    span: oxc_span::Span::empty(0),
5046                    members: vec![],
5047                    is_side_effect_used: false,
5048                    super_class: None,
5049                    deprecated: false,
5050                    deprecated_reason: None,
5051                },
5052                fallow_types::extract::ExportInfo {
5053                    name: crate::source::ExportName::Named("baz".into()),
5054                    local_name: None,
5055                    is_type_only: false,
5056                    visibility: crate::source::VisibilityTag::None,
5057                    expected_unused_reason: None,
5058                    span: oxc_span::Span::new(0, 0),
5059                    members: vec![],
5060                    is_side_effect_used: false,
5061                    super_class: None,
5062                    deprecated: false,
5063                    deprecated_reason: None,
5064                },
5065            ]
5066            .into(),
5067            ..Default::default()
5068        }];
5069
5070        let graph = build_test_graph(&files, &[], &resolved_modules);
5071
5072        let mut module = make_module_info(
5073            0,
5074            10,
5075            vec![fallow_types::extract::FunctionComplexity {
5076                name: "fn_a".into(),
5077                is_private_member: false,
5078                line: 1,
5079                col: 0,
5080                cyclomatic: 1,
5081                cognitive: 0,
5082                line_count: 10,
5083                param_count: 0,
5084                react_hook_count: 0,
5085                react_jsx_max_depth: 0,
5086                react_prop_count: 0,
5087                source_hash: None,
5088                contributions: Vec::new(),
5089            }],
5090        );
5091        module.exports = vec![
5092            fallow_types::extract::ExportInfo {
5093                name: crate::source::ExportName::Named("foo".into()),
5094                local_name: None,
5095                is_type_only: false,
5096                visibility: crate::source::VisibilityTag::None,
5097                expected_unused_reason: None,
5098                span: oxc_span::Span::empty(0),
5099                members: vec![],
5100                is_side_effect_used: false,
5101                super_class: None,
5102                deprecated: false,
5103                deprecated_reason: None,
5104            },
5105            fallow_types::extract::ExportInfo {
5106                name: crate::source::ExportName::Named("bar".into()),
5107                local_name: None,
5108                is_type_only: false,
5109                visibility: crate::source::VisibilityTag::None,
5110                expected_unused_reason: None,
5111                span: oxc_span::Span::empty(0),
5112                members: vec![],
5113                is_side_effect_used: false,
5114                super_class: None,
5115                deprecated: false,
5116                deprecated_reason: None,
5117            },
5118        ]
5119        .into();
5120        let modules = vec![module];
5121
5122        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5123            rustc_hash::FxHashMap::default();
5124        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5125
5126        let mut results = fallow_types::results::AnalysisResults::default();
5127        for name in ["foo", "bar", "baz"] {
5128            results.unused_exports.push(
5129                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5130                    fallow_types::results::UnusedExport {
5131                        path: path_a.clone(),
5132                        export_name: name.into(),
5133                        is_type_only: false,
5134                        line: 1,
5135                        col: 0,
5136                        span_start: 0,
5137                        is_re_export: name == "baz",
5138                        deprecated: false,
5139                        deprecated_reason: None,
5140                    },
5141                ),
5142            );
5143        }
5144
5145        let output = crate::results::DeadCodeAnalysisArtifacts {
5146            results,
5147            timings: None,
5148            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5149            modules: None,
5150            files: None,
5151            script_used_packages: rustc_hash::FxHashSet::default(),
5152            trace_provenance: crate::trace::TraceProvenance::default(),
5153            file_hashes: rustc_hash::FxHashMap::default(),
5154        };
5155
5156        let result = compute_file_scores_default(
5157            &modules,
5158            &file_paths,
5159            None,
5160            output,
5161            None,
5162            std::path::Path::new("/project"),
5163        )
5164        .unwrap();
5165        assert_eq!(result.analysis_counts.total_exports, 3);
5166        assert_eq!(result.analysis_counts.dead_exports, 3);
5167    }
5168
5169    #[test]
5170    fn compute_file_scores_module_not_in_file_paths_skipped() {
5171        let path_a = std::path::PathBuf::from("/src/a.ts");
5172        let files = vec![crate::discover::DiscoveredFile {
5173            id: crate::discover::FileId(0),
5174            path: path_a.clone(),
5175            size_bytes: 100,
5176        }];
5177
5178        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5179            file_id: crate::discover::FileId(0),
5180            path: path_a,
5181            ..Default::default()
5182        }];
5183
5184        let graph = build_test_graph(&files, &[], &resolved_modules);
5185
5186        let modules = vec![make_module_info(
5187            0,
5188            10,
5189            vec![fallow_types::extract::FunctionComplexity {
5190                name: "fn_a".into(),
5191                is_private_member: false,
5192                line: 1,
5193                col: 0,
5194                cyclomatic: 2,
5195                cognitive: 1,
5196                line_count: 10,
5197                param_count: 0,
5198                react_hook_count: 0,
5199                react_jsx_max_depth: 0,
5200                react_prop_count: 0,
5201                source_hash: None,
5202                contributions: Vec::new(),
5203            }],
5204        )];
5205
5206        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5207            rustc_hash::FxHashMap::default();
5208
5209        let output = crate::results::DeadCodeAnalysisArtifacts {
5210            results: fallow_types::results::AnalysisResults::default(),
5211            timings: None,
5212            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5213            modules: None,
5214            files: None,
5215            script_used_packages: rustc_hash::FxHashSet::default(),
5216            trace_provenance: crate::trace::TraceProvenance::default(),
5217            file_hashes: rustc_hash::FxHashMap::default(),
5218        };
5219
5220        let result = compute_file_scores_default(
5221            &modules,
5222            &file_paths,
5223            None,
5224            output,
5225            None,
5226            std::path::Path::new("/project"),
5227        )
5228        .unwrap();
5229        assert!(result.scores.is_empty());
5230    }
5231
5232    #[test]
5233    fn compute_file_scores_mi_rounded_to_one_decimal() {
5234        let path_a = std::path::PathBuf::from("/src/a.ts");
5235        let files = vec![crate::discover::DiscoveredFile {
5236            id: crate::discover::FileId(0),
5237            path: path_a.clone(),
5238            size_bytes: 100,
5239        }];
5240
5241        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5242            file_id: crate::discover::FileId(0),
5243            path: path_a.clone(),
5244            ..Default::default()
5245        }];
5246
5247        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5248
5249        let modules = vec![make_module_info(
5250            0,
5251            100,
5252            vec![fallow_types::extract::FunctionComplexity {
5253                name: "fn".into(),
5254                is_private_member: false,
5255                line: 1,
5256                col: 0,
5257                cyclomatic: 7,
5258                cognitive: 3,
5259                line_count: 100,
5260                param_count: 0,
5261                react_hook_count: 0,
5262                react_jsx_max_depth: 0,
5263                react_prop_count: 0,
5264                source_hash: None,
5265                contributions: Vec::new(),
5266            }],
5267        )];
5268
5269        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5270            rustc_hash::FxHashMap::default();
5271        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5272
5273        let output = crate::results::DeadCodeAnalysisArtifacts {
5274            results: fallow_types::results::AnalysisResults::default(),
5275            timings: None,
5276            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5277            modules: None,
5278            files: None,
5279            script_used_packages: rustc_hash::FxHashSet::default(),
5280            trace_provenance: crate::trace::TraceProvenance::default(),
5281            file_hashes: rustc_hash::FxHashMap::default(),
5282        };
5283
5284        let result = compute_file_scores_default(
5285            &modules,
5286            &file_paths,
5287            None,
5288            output,
5289            None,
5290            std::path::Path::new("/project"),
5291        )
5292        .unwrap();
5293        let mi = result.scores[0].maintainability_index;
5294        let rounded = (mi * 10.0).round() / 10.0;
5295        assert!((mi - rounded).abs() < f64::EPSILON);
5296    }
5297
5298    #[test]
5299    fn compute_file_scores_value_export_counts_tracked() {
5300        let path_a = std::path::PathBuf::from("/src/a.ts");
5301        let files = vec![crate::discover::DiscoveredFile {
5302            id: crate::discover::FileId(0),
5303            path: path_a.clone(),
5304            size_bytes: 100,
5305        }];
5306
5307        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5308            file_id: crate::discover::FileId(0),
5309            path: path_a.clone(),
5310            exports: vec![
5311                fallow_types::extract::ExportInfo {
5312                    name: crate::source::ExportName::Named("a".into()),
5313                    local_name: None,
5314                    is_type_only: false,
5315                    visibility: crate::source::VisibilityTag::None,
5316                    expected_unused_reason: None,
5317                    span: oxc_span::Span::empty(0),
5318                    members: vec![],
5319                    is_side_effect_used: false,
5320                    super_class: None,
5321                    deprecated: false,
5322                    deprecated_reason: None,
5323                },
5324                fallow_types::extract::ExportInfo {
5325                    name: crate::source::ExportName::Named("b".into()),
5326                    local_name: None,
5327                    is_type_only: false,
5328                    visibility: crate::source::VisibilityTag::None,
5329                    expected_unused_reason: None,
5330                    span: oxc_span::Span::empty(0),
5331                    members: vec![],
5332                    is_side_effect_used: false,
5333                    super_class: None,
5334                    deprecated: false,
5335                    deprecated_reason: None,
5336                },
5337                fallow_types::extract::ExportInfo {
5338                    name: crate::source::ExportName::Named("T".into()),
5339                    local_name: None,
5340                    is_type_only: true,
5341                    visibility: crate::source::VisibilityTag::None,
5342                    expected_unused_reason: None,
5343                    span: oxc_span::Span::empty(0),
5344                    members: vec![],
5345                    is_side_effect_used: false,
5346                    super_class: None,
5347                    deprecated: false,
5348                    deprecated_reason: None,
5349                },
5350            ]
5351            .into(),
5352            ..Default::default()
5353        }];
5354
5355        let graph = build_test_graph(&files, &[], &resolved_modules);
5356
5357        let modules = vec![make_module_info(
5358            0,
5359            10,
5360            vec![fallow_types::extract::FunctionComplexity {
5361                name: "fn_a".into(),
5362                is_private_member: false,
5363                line: 1,
5364                col: 0,
5365                cyclomatic: 2,
5366                cognitive: 1,
5367                line_count: 10,
5368                param_count: 0,
5369                react_hook_count: 0,
5370                react_jsx_max_depth: 0,
5371                react_prop_count: 0,
5372                source_hash: None,
5373                contributions: Vec::new(),
5374            }],
5375        )];
5376
5377        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5378            rustc_hash::FxHashMap::default();
5379        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5380
5381        let output = crate::results::DeadCodeAnalysisArtifacts {
5382            results: fallow_types::results::AnalysisResults::default(),
5383            timings: None,
5384            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5385            modules: None,
5386            files: None,
5387            script_used_packages: rustc_hash::FxHashSet::default(),
5388            trace_provenance: crate::trace::TraceProvenance::default(),
5389            file_hashes: rustc_hash::FxHashMap::default(),
5390        };
5391
5392        let result = compute_file_scores_default(
5393            &modules,
5394            &file_paths,
5395            None,
5396            output,
5397            None,
5398            std::path::Path::new("/project"),
5399        )
5400        .unwrap();
5401        assert_eq!(result.value_export_counts[&path_a], 2);
5402    }
5403
5404    #[test]
5405    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
5406        let path_a = std::path::PathBuf::from("/src/simple.ts");
5407        let files = vec![crate::discover::DiscoveredFile {
5408            id: crate::discover::FileId(0),
5409            path: path_a.clone(),
5410            size_bytes: 100,
5411        }];
5412
5413        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5414            file_id: crate::discover::FileId(0),
5415            path: path_a.clone(),
5416            ..Default::default()
5417        }];
5418
5419        let graph = build_test_graph(&files, &[], &resolved_modules);
5420
5421        let modules = vec![make_module_info(
5422            0,
5423            10,
5424            vec![fallow_types::extract::FunctionComplexity {
5425                name: "trivial".into(),
5426                is_private_member: false,
5427                line: 1,
5428                col: 0,
5429                cyclomatic: 1,
5430                cognitive: 0,
5431                line_count: 10,
5432                param_count: 0,
5433                react_hook_count: 0,
5434                react_jsx_max_depth: 0,
5435                react_prop_count: 0,
5436                source_hash: None,
5437                contributions: Vec::new(),
5438            }],
5439        )];
5440
5441        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5442            rustc_hash::FxHashMap::default();
5443        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5444
5445        let output = crate::results::DeadCodeAnalysisArtifacts {
5446            results: fallow_types::results::AnalysisResults::default(),
5447            timings: None,
5448            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5449            modules: None,
5450            files: None,
5451            script_used_packages: rustc_hash::FxHashSet::default(),
5452            trace_provenance: crate::trace::TraceProvenance::default(),
5453            file_hashes: rustc_hash::FxHashMap::default(),
5454        };
5455
5456        let result = compute_file_scores_default(
5457            &modules,
5458            &file_paths,
5459            None,
5460            output,
5461            None,
5462            std::path::Path::new("/project"),
5463        )
5464        .unwrap();
5465        assert!(!result.top_complex_fns.contains_key(&path_a));
5466    }
5467
5468    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
5469        fallow_types::extract::FunctionComplexity {
5470            name: "test_fn".into(),
5471            is_private_member: false,
5472            line: 1,
5473            col: 0,
5474            cyclomatic,
5475            cognitive: 0,
5476            line_count: 10,
5477            param_count: 0,
5478            react_hook_count: 0,
5479            react_jsx_max_depth: 0,
5480            react_prop_count: 0,
5481            source_hash: None,
5482            contributions: Vec::new(),
5483        }
5484    }
5485
5486    fn make_named_fn_complexity(
5487        name: &str,
5488        line: u32,
5489        cyclomatic: u16,
5490    ) -> fallow_types::extract::FunctionComplexity {
5491        fallow_types::extract::FunctionComplexity {
5492            name: name.into(),
5493            is_private_member: false,
5494            line,
5495            col: 0,
5496            cyclomatic,
5497            cognitive: 0,
5498            line_count: 10,
5499            param_count: 0,
5500            react_hook_count: 0,
5501            react_jsx_max_depth: 0,
5502            react_prop_count: 0,
5503            source_hash: None,
5504            contributions: Vec::new(),
5505        }
5506    }
5507
5508    fn crap_override_entry(
5509        files: &[&str],
5510        functions: &[&str],
5511        max_crap: Option<f64>,
5512    ) -> fallow_config::HealthThresholdOverride {
5513        fallow_config::HealthThresholdOverride {
5514            files: files.iter().map(ToString::to_string).collect(),
5515            functions: functions.iter().map(ToString::to_string).collect(),
5516            max_cyclomatic: None,
5517            max_cognitive: None,
5518            max_crap,
5519            max_unit_size: None,
5520            reason: Some("test override".into()),
5521        }
5522    }
5523
5524    fn estimated_signals_with(
5525        resolver: &ThresholdOverrideResolver,
5526        relative: &str,
5527        enforce_crap: bool,
5528        complexity: &[fallow_types::extract::FunctionComplexity],
5529    ) -> CrapThresholdSignals {
5530        let ceilings = CrapCeilingLookup::new(
5531            CrapScoreThresholds {
5532                resolver,
5533                enforce_crap,
5534            },
5535            std::path::Path::new(relative),
5536        );
5537        compute_crap_scores_estimated(
5538            complexity,
5539            &rustc_hash::FxHashSet::default(),
5540            false,
5541            fallow_output::CoverageSource::Estimated,
5542            &ceilings,
5543        )
5544        .signals
5545    }
5546
5547    #[test]
5548    fn crap_counting_exempts_functions_under_override_ceiling() {
5549        // The issue's repro: two untested cyclomatic-10 functions (CRAP 110)
5550        // under an override raising maxCrap to 500 on the file.
5551        let resolver =
5552            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5553        let fns = vec![
5554            make_named_fn_complexity("a", 1, 10),
5555            make_named_fn_complexity("b", 12, 10),
5556        ];
5557
5558        let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5559        assert_eq!(covered.above, 0);
5560        assert_eq!(covered.exempted, 2);
5561        assert_eq!(covered.min_ceiling, Some(500.0));
5562
5563        let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
5564        assert_eq!(elsewhere.above, 2);
5565        assert_eq!(elsewhere.exempted, 0);
5566        assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
5567    }
5568
5569    #[test]
5570    fn crap_counting_insufficient_override_keeps_count() {
5571        let resolver =
5572            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
5573        let fns = vec![
5574            make_named_fn_complexity("a", 1, 10),
5575            make_named_fn_complexity("b", 12, 10),
5576        ];
5577
5578        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5579        assert_eq!(signals.above, 2);
5580        assert_eq!(signals.exempted, 0);
5581        assert_eq!(signals.min_ceiling, Some(50.0));
5582    }
5583
5584    #[test]
5585    fn crap_counting_partial_function_override() {
5586        // Only `a` is exempted; `b` keeps the global ceiling, which is also
5587        // the file's lowest effective ceiling.
5588        let resolver =
5589            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
5590        let fns = vec![
5591            make_named_fn_complexity("a", 1, 10),
5592            make_named_fn_complexity("b", 12, 10),
5593        ];
5594
5595        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5596        assert_eq!(signals.above, 1);
5597        assert_eq!(signals.exempted, 1);
5598        assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
5599    }
5600
5601    #[test]
5602    fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
5603        // Global maxCrap 0 disables enforcement: nothing is above threshold
5604        // and every canonical-baseline breach is disclosed as exempt.
5605        let resolver = test_crap_resolver(0.0);
5606        let fns = vec![
5607            make_named_fn_complexity("a", 1, 10),
5608            make_named_fn_complexity("b", 12, 10),
5609            make_named_fn_complexity("tiny", 24, 1),
5610        ];
5611
5612        let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
5613        assert_eq!(signals.above, 0);
5614        assert_eq!(signals.exempted, 2);
5615    }
5616
5617    #[test]
5618    fn crap_counting_stricter_ceiling_never_counts_exempt() {
5619        // A ceiling below the canonical baseline flags the band between it and
5620        // 30 as above-threshold, never as exempt.
5621        let resolver = test_crap_resolver(10.0);
5622        let fns = vec![make_named_fn_complexity("a", 1, 4)]; // untested CRAP 20
5623
5624        let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
5625        assert_eq!(signals.above, 1);
5626        assert_eq!(signals.exempted, 0);
5627    }
5628
5629    #[test]
5630    fn crap_counting_uses_rounded_value_at_boundary() {
5631        // Istanbul coverage tuned so unrounded CRAP is 29.96, which rounds to
5632        // the stored per-function 30.0. The findings pipeline compares the
5633        // ROUNDED value against the ceiling and emits a finding; the count
5634        // must agree at the same boundary (issue #2228).
5635        let funcs = vec![make_fn_complexity(10)];
5636        let mut functions = rustc_hash::FxHashMap::default();
5637        functions.insert(("test_fn".to_string(), 1, 0), 41.56);
5638        let file_cov = test_istanbul_file_coverage(functions, false);
5639
5640        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5641        assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
5642        assert_eq!(result.signals.above, 1);
5643        assert_eq!(result.signals.exempted, 0);
5644    }
5645
5646    #[test]
5647    fn compute_file_scores_discloses_override_exemption_on_row() {
5648        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5649        let files = vec![crate::discover::DiscoveredFile {
5650            id: crate::discover::FileId(0),
5651            path: path_a.clone(),
5652            size_bytes: 100,
5653        }];
5654        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5655            file_id: crate::discover::FileId(0),
5656            path: path_a.clone(),
5657            ..Default::default()
5658        }];
5659        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5660        let modules = vec![make_module_info(
5661            0,
5662            26,
5663            vec![
5664                make_named_fn_complexity("a", 1, 10),
5665                make_named_fn_complexity("b", 12, 10),
5666            ],
5667        )];
5668        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5669            rustc_hash::FxHashMap::default();
5670        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5671        let output = crate::results::DeadCodeAnalysisArtifacts {
5672            results: fallow_types::results::AnalysisResults::default(),
5673            timings: None,
5674            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5675            modules: None,
5676            files: None,
5677            script_used_packages: rustc_hash::FxHashSet::default(),
5678            trace_provenance: crate::trace::TraceProvenance::default(),
5679            file_hashes: rustc_hash::FxHashMap::default(),
5680        };
5681
5682        let resolver =
5683            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5684        let result = compute_file_scores(
5685            FileScoreComputeInput {
5686                modules: &modules,
5687                file_paths: &file_paths,
5688                changed_files: None,
5689                istanbul_coverage: None,
5690                root: std::path::Path::new("/project"),
5691                crap_thresholds: CrapScoreThresholds {
5692                    resolver: &resolver,
5693                    enforce_crap: true,
5694                },
5695            },
5696            output,
5697        )
5698        .unwrap();
5699
5700        assert_eq!(result.scores.len(), 1);
5701        let score = &result.scores[0];
5702        assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
5703        assert_eq!(score.crap_above_threshold, 0);
5704        assert_eq!(score.crap_exempted, 2);
5705        assert_eq!(score.crap_effective_threshold, Some(500.0));
5706        assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
5707        assert_eq!(
5708            file_score_concern_axis(score, CRAP_THRESHOLD),
5709            FileScoreConcern::Structural
5710        );
5711    }
5712
5713    #[test]
5714    fn compute_file_scores_raised_global_omits_row_threshold() {
5715        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5716        let files = vec![crate::discover::DiscoveredFile {
5717            id: crate::discover::FileId(0),
5718            path: path_a.clone(),
5719            size_bytes: 100,
5720        }];
5721        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5722            file_id: crate::discover::FileId(0),
5723            path: path_a.clone(),
5724            ..Default::default()
5725        }];
5726        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5727        let modules = vec![make_module_info(
5728            0,
5729            26,
5730            vec![
5731                make_named_fn_complexity("a", 1, 10),
5732                make_named_fn_complexity("b", 12, 10),
5733            ],
5734        )];
5735        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5736            rustc_hash::FxHashMap::default();
5737        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5738        let output = crate::results::DeadCodeAnalysisArtifacts {
5739            results: fallow_types::results::AnalysisResults::default(),
5740            timings: None,
5741            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5742            modules: None,
5743            files: None,
5744            script_used_packages: rustc_hash::FxHashSet::default(),
5745            trace_provenance: crate::trace::TraceProvenance::default(),
5746            file_hashes: rustc_hash::FxHashMap::default(),
5747        };
5748
5749        // Raised via the global (`--max-crap 5000`), not an override: the row
5750        // must not repeat the run global as its own effective threshold.
5751        let resolver = test_crap_resolver(5000.0);
5752        let result = compute_file_scores(
5753            FileScoreComputeInput {
5754                modules: &modules,
5755                file_paths: &file_paths,
5756                changed_files: None,
5757                istanbul_coverage: None,
5758                root: std::path::Path::new("/project"),
5759                crap_thresholds: CrapScoreThresholds {
5760                    resolver: &resolver,
5761                    enforce_crap: true,
5762                },
5763            },
5764            output,
5765        )
5766        .unwrap();
5767
5768        assert_eq!(result.scores.len(), 1);
5769        let score = &result.scores[0];
5770        assert_eq!(score.crap_above_threshold, 0);
5771        assert_eq!(score.crap_exempted, 2);
5772        assert_eq!(score.crap_effective_threshold, None);
5773        assert!(file_score_fully_crap_exempt(score, 5000.0));
5774        assert_eq!(
5775            file_score_concern_axis(score, 5000.0),
5776            FileScoreConcern::Structural
5777        );
5778    }
5779
5780    /// Untested-file aggregation on the shipped estimated path: the reported
5781    /// max is the highest rounded score, `above` counts only the units at or
5782    /// over their effective ceiling, and the synthetic template and module
5783    /// units leave the CRAP dimension entirely.
5784    #[test]
5785    fn estimated_crap_untested_aggregates_over_real_units_only() {
5786        let funcs = vec![
5787            make_named_fn_complexity("below", 1, 4),
5788            make_named_fn_complexity("at_threshold", 2, 5),
5789            make_named_fn_complexity("above_threshold", 3, 8),
5790            make_named_fn_complexity("<template>", 4, 21),
5791            make_named_fn_complexity("<module>", 5, 21),
5792        ];
5793        let result = estimated_crap_default(
5794            &funcs,
5795            &rustc_hash::FxHashSet::default(),
5796            false,
5797            fallow_output::CoverageSource::Estimated,
5798        );
5799        assert!((result.max_crap - 72.0).abs() < f64::EPSILON, "{result:#?}");
5800        assert_eq!(result.signals.above, 2);
5801        assert_eq!(result.per_function.len(), 3);
5802    }
5803
5804    #[test]
5805    fn crap_formula_full_coverage() {
5806        let result = crap_formula(10.0, 100.0);
5807        assert!((result - 10.0).abs() < f64::EPSILON);
5808    }
5809
5810    #[test]
5811    fn crap_formula_zero_coverage() {
5812        let result = crap_formula(5.0, 0.0);
5813        assert!((result - 30.0).abs() < f64::EPSILON);
5814    }
5815
5816    #[test]
5817    fn crap_formula_partial_coverage() {
5818        let result = crap_formula(10.0, 50.0);
5819        assert!((result - 22.5).abs() < f64::EPSILON);
5820    }
5821
5822    #[test]
5823    fn crap_formula_high_coverage_low_complexity() {
5824        let result = crap_formula(2.0, 90.0);
5825        assert!((result - 2.004).abs() < 0.001);
5826    }
5827
5828    /// Pin the exact cyclomatic value at which the default CRAP gate (30.0)
5829    /// trips for each estimated-coverage tier. These numbers back the
5830    /// changelog and docs wording: 5 at the 0% tier, 10 at the 40% indirect
5831    /// tier, 28 at the 85% direct tier.
5832    #[test]
5833    fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
5834        for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
5835            assert!(
5836                crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
5837                "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
5838            );
5839            assert!(
5840                crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
5841                "cyclomatic {} at {coverage_pct}% must stay under the gate",
5842                gate_cc - 1.0
5843            );
5844        }
5845    }
5846
5847    #[test]
5848    fn istanbul_crap_excludes_synthetic_template_units() {
5849        let funcs = vec![
5850            make_named_fn_complexity("<template>", 1, 21),
5851            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5852            make_fn_complexity(6),
5853        ];
5854        let result = istanbul_crap_default(&funcs, None, false);
5855        assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
5856        assert_eq!(result.signals.above, 1);
5857        assert_eq!(
5858            result.total, 1,
5859            "template units must not count as unmatched"
5860        );
5861        assert_eq!(result.per_function.len(), 1);
5862    }
5863
5864    #[test]
5865    fn estimated_crap_excludes_synthetic_template_units() {
5866        let funcs = vec![
5867            make_named_fn_complexity("<template>", 1, 21),
5868            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5869        ];
5870        let result = estimated_crap_default(
5871            &funcs,
5872            &rustc_hash::FxHashSet::default(),
5873            false,
5874            fallow_output::CoverageSource::Estimated,
5875        );
5876        assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
5877        assert_eq!(result.signals.above, 0);
5878        assert!(result.per_function.is_empty());
5879    }
5880
5881    #[test]
5882    fn istanbul_crap_with_coverage_data() {
5883        let funcs = vec![make_fn_complexity(10)];
5884        let mut functions = rustc_hash::FxHashMap::default();
5885        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
5886        let file_cov = test_istanbul_file_coverage(functions, false);
5887        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5888        assert!((result.max_crap - 10.8).abs() < 0.1);
5889        assert_eq!(result.signals.above, 0);
5890    }
5891
5892    #[test]
5893    fn istanbul_crap_falls_back_to_binary_when_no_match() {
5894        let funcs = vec![make_fn_complexity(6)];
5895        let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
5896        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5897        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
5898        assert_eq!(result.signals.above, 1);
5899    }
5900
5901    /// A file tests reach, with no coverage data for it at all, keeps the
5902    /// static estimate rather than being scored as fully covered.
5903    #[test]
5904    fn istanbul_crap_uses_the_static_estimate_when_no_file_coverage() {
5905        let funcs = vec![make_fn_complexity(5)];
5906        let result = istanbul_crap_default(&funcs, None, true);
5907        // The reported score is rounded to one decimal, so compare against
5908        // the estimate rather than the formula's last bit.
5909        assert!((result.max_crap - 10.4).abs() < 1e-9);
5910        assert_eq!(result.signals.above, 0);
5911    }
5912
5913    #[test]
5914    fn istanbul_crap_zero_coverage_matches_binary_untested() {
5915        let funcs = vec![make_fn_complexity(5)];
5916        let mut functions = rustc_hash::FxHashMap::default();
5917        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
5918        let file_cov = test_istanbul_file_coverage(functions, false);
5919        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5920        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
5921        assert_eq!(result.signals.above, 1);
5922    }
5923
5924    #[test]
5925    fn estimated_crap_direct_test_reference() {
5926        let funcs = vec![make_fn_complexity(10)];
5927        let mut refs = rustc_hash::FxHashSet::default();
5928        refs.insert("test_fn".to_string());
5929        let result = estimated_crap_default(
5930            &funcs,
5931            &refs,
5932            true,
5933            fallow_output::CoverageSource::Estimated,
5934        );
5935        let (max, above) = (result.max_crap, result.signals.above);
5936        assert!((max - 10.3).abs() < 0.1);
5937        assert_eq!(above, 0);
5938    }
5939
5940    #[test]
5941    fn estimated_crap_indirect_test_reachable() {
5942        let funcs = vec![make_fn_complexity(10)];
5943        let refs = rustc_hash::FxHashSet::default();
5944        let result = estimated_crap_default(
5945            &funcs,
5946            &refs,
5947            true,
5948            fallow_output::CoverageSource::Estimated,
5949        );
5950        let (max, above) = (result.max_crap, result.signals.above);
5951        assert!((max - 31.6).abs() < 0.1);
5952        assert_eq!(above, 1);
5953    }
5954
5955    #[test]
5956    fn estimated_crap_untested_file() {
5957        let funcs = vec![make_fn_complexity(5)];
5958        let refs = rustc_hash::FxHashSet::default();
5959        let result = estimated_crap_default(
5960            &funcs,
5961            &refs,
5962            false,
5963            fallow_output::CoverageSource::Estimated,
5964        );
5965        let (max, above) = (result.max_crap, result.signals.above);
5966        assert!((max - 30.0).abs() < f64::EPSILON);
5967        assert_eq!(above, 1);
5968    }
5969
5970    #[test]
5971    fn estimated_crap_low_complexity_direct_ref() {
5972        let funcs = vec![make_fn_complexity(2)];
5973        let mut refs = rustc_hash::FxHashSet::default();
5974        refs.insert("test_fn".to_string());
5975        let result = estimated_crap_default(
5976            &funcs,
5977            &refs,
5978            true,
5979            fallow_output::CoverageSource::Estimated,
5980        );
5981        let (max, above) = (result.max_crap, result.signals.above);
5982        assert!(max < 3.0);
5983        assert_eq!(above, 0);
5984    }
5985
5986    #[test]
5987    fn estimated_crap_empty() {
5988        let refs = rustc_hash::FxHashSet::default();
5989        let result =
5990            estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
5991        let (max, above) = (result.max_crap, result.signals.above);
5992        assert!((max).abs() < f64::EPSILON);
5993        assert_eq!(above, 0);
5994    }
5995
5996    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
5997        fallow_graph::graph::ExportSymbol {
5998            name: fallow_types::extract::ExportName::Named(name.into()),
5999            is_type_only,
6000            is_side_effect_used: false,
6001            visibility: crate::source::VisibilityTag::None,
6002            expected_unused_reason: None,
6003            span: oxc_span::Span::default(),
6004            references: vec![],
6005            reference_paths: Vec::new(),
6006            members: vec![],
6007            deprecated: false,
6008            deprecated_reason: None,
6009        }
6010    }
6011
6012    #[test]
6013    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
6014        let path = std::path::Path::new("src/types.ts");
6015        let exports = vec![
6016            make_export("MyInterface", true),
6017            make_export("MyType", true),
6018            make_export("myFunction", false),
6019        ];
6020        let unused_files = rustc_hash::FxHashSet::default();
6021        let mut unused_by_path = rustc_hash::FxHashMap::default();
6022        unused_by_path.insert(path, 1_usize); // 1 unused value export
6023
6024        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6025        assert!((ratio - 1.0).abs() < f64::EPSILON);
6026    }
6027
6028    #[test]
6029    fn dead_code_ratio_only_type_exports_returns_zero() {
6030        let path = std::path::Path::new("src/types.ts");
6031        let exports = vec![
6032            make_export("MyInterface", true),
6033            make_export("MyType", true),
6034        ];
6035        let unused_files = rustc_hash::FxHashSet::default();
6036        let unused_by_path = rustc_hash::FxHashMap::default();
6037
6038        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6039        assert!(ratio.abs() < f64::EPSILON);
6040    }
6041
6042    #[test]
6043    fn dead_code_ratio_mixed_exports_counts_only_values() {
6044        let path = std::path::Path::new("src/component.ts");
6045        let exports = vec![
6046            make_export("Props", true),
6047            make_export("State", true),
6048            make_export("Component", false),
6049            make_export("helper", false),
6050        ];
6051        let unused_files = rustc_hash::FxHashSet::default();
6052        let mut unused_by_path = rustc_hash::FxHashMap::default();
6053        unused_by_path.insert(path, 1_usize);
6054
6055        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6056        assert!((ratio - 0.5).abs() < f64::EPSILON);
6057    }
6058
6059    fn write_single_file_istanbul_fixture(
6060        coverage_path: &std::path::Path,
6061        source_path: &std::path::Path,
6062        fn_map: &serde_json::Value,
6063        function_hits: &serde_json::Value,
6064    ) {
6065        write_single_file_istanbul_fixture_with_statements(
6066            coverage_path,
6067            source_path,
6068            fn_map,
6069            function_hits,
6070            &serde_json::json!({}),
6071            &serde_json::json!({}),
6072        );
6073    }
6074
6075    fn write_single_file_istanbul_fixture_with_statements(
6076        coverage_path: &std::path::Path,
6077        source_path: &std::path::Path,
6078        fn_map: &serde_json::Value,
6079        function_hits: &serde_json::Value,
6080        statement_map: &serde_json::Value,
6081        statement_hits: &serde_json::Value,
6082    ) {
6083        let mut root = serde_json::Map::new();
6084        root.insert(
6085            source_path.to_string_lossy().into_owned(),
6086            serde_json::json!({
6087                "path": source_path.to_string_lossy().into_owned(),
6088                "statementMap": statement_map,
6089                "fnMap": fn_map,
6090                "branchMap": {},
6091                "s": statement_hits,
6092                "f": function_hits,
6093                "b": {}
6094            }),
6095        );
6096
6097        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
6098    }
6099
6100    /// `v8-to-istanbul`, which is what c8 and nyc write, records `column: -1`
6101    /// for the implicit else of a bare `if`. Positions are unsigned, so the
6102    /// strict parse rejected the whole map over a coordinate in a section
6103    /// nothing here reads. Geometry from a map generated by running real V8
6104    /// coverage through v8-to-istanbul 9.2.0.
6105    #[test]
6106    fn a_negative_branch_coordinate_does_not_cost_the_whole_map() {
6107        let temp = tempfile::TempDir::new().unwrap();
6108        let source_path = temp.path().join("src/pick.ts");
6109        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6110        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6111
6112        let coverage_path = temp.path().join("coverage-final.json");
6113        let source = source_path.to_string_lossy().into_owned();
6114        std::fs::write(
6115            &coverage_path,
6116            serde_json::to_string(&serde_json::json!({
6117                source.clone(): {
6118                    "path": source,
6119                    "statementMap": {},
6120                    "fnMap": {
6121                        "0": {
6122                            "name": "pick",
6123                            "line": 1,
6124                            "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 20 } },
6125                            "loc": { "start": { "line": 1, "column": 41 }, "end": { "line": 6, "column": 1 } }
6126                        }
6127                    },
6128                    "branchMap": {
6129                        "0": {
6130                            "type": "branch",
6131                            "line": 5,
6132                            "loc": {
6133                                "start": { "line": 5, "column": -1 },
6134                                "end": { "line": 6, "column": 0 }
6135                            },
6136                            "locations": [
6137                                {
6138                                    "start": { "line": 5, "column": -1 },
6139                                    "end": { "line": 6, "column": 0 }
6140                                }
6141                            ]
6142                        }
6143                    },
6144                    "s": {},
6145                    "f": { "0": 2 },
6146                    "b": { "0": [1, 0] }
6147                }
6148            }))
6149            .unwrap(),
6150        )
6151        .unwrap();
6152
6153        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6154        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6155        let file_coverage = coverage.get(&canonical_source).unwrap();
6156
6157        assert_eq!(file_coverage.lookup("pick", 1, 16), Some(100.0));
6158    }
6159
6160    /// Raw V8 coverage and `oxc-coverage-instrument` record an accessor as
6161    /// `get area`, istanbul-lib-instrument leaves it anonymous, and fallow
6162    /// extracts the unit as `area`. Both spellings must reach the record.
6163    #[test]
6164    fn an_accessor_answers_to_its_property_name() {
6165        let temp = tempfile::TempDir::new().unwrap();
6166        let source_path = temp.path().join("src/box.ts");
6167        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6168        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6169
6170        let coverage_path = temp.path().join("coverage-final.json");
6171        write_single_file_istanbul_fixture(
6172            &coverage_path,
6173            &source_path,
6174            &serde_json::json!({
6175                "0": {
6176                    "name": "get area",
6177                    "line": 4,
6178                    "decl": { "start": { "line": 4, "column": 6 }, "end": { "line": 4, "column": 10 } },
6179                    "loc": { "start": { "line": 4, "column": 13 }, "end": { "line": 6, "column": 3 } }
6180                }
6181            }),
6182            &serde_json::json!({ "0": 0 }),
6183        );
6184
6185        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6186        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6187        let file_coverage = coverage.get(&canonical_source).unwrap();
6188
6189        // Fallow extracts the accessor under the property name alone.
6190        assert_eq!(file_coverage.lookup("area", 4, 6), Some(0.0));
6191        // The producer's own spelling still resolves.
6192        assert_eq!(file_coverage.lookup("get area", 4, 6), Some(0.0));
6193    }
6194
6195    #[test]
6196    fn resolve_relative_to_root_joins_relative_with_project_root() {
6197        let resolved = resolve_relative_to_root(
6198            std::path::Path::new("coverage/coverage-final.json"),
6199            Some(std::path::Path::new("/work/my-app")),
6200        );
6201        assert_eq!(
6202            resolved,
6203            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
6204        );
6205    }
6206
6207    #[test]
6208    fn resolve_relative_to_root_returns_absolute_unchanged() {
6209        let resolved = resolve_relative_to_root(
6210            std::path::Path::new("/tmp/coverage-final.json"),
6211            Some(std::path::Path::new("/work/my-app")),
6212        );
6213        assert_eq!(
6214            resolved,
6215            std::path::PathBuf::from("/tmp/coverage-final.json")
6216        );
6217    }
6218
6219    #[test]
6220    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
6221        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
6222        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
6223        assert_eq!(resolved, path);
6224    }
6225
6226    #[cfg(windows)]
6227    #[test]
6228    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
6229        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
6230        let resolved =
6231            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
6232        assert_eq!(resolved, path);
6233    }
6234
6235    #[test]
6236    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
6237        let resolved =
6238            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
6239        assert_eq!(
6240            resolved,
6241            std::path::PathBuf::from("coverage/coverage-final.json")
6242        );
6243    }
6244
6245    /// nyc and some Jest setups record project-relative keys. Resolving one
6246    /// against the process directory misses the file, and a run from anywhere
6247    /// but the project root loses the whole map at once.
6248    #[test]
6249    fn load_istanbul_coverage_resolves_relative_map_keys_against_project_root() {
6250        let temp = tempfile::TempDir::new().unwrap();
6251        let source_path = temp.path().join("src/index.ts");
6252        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6253        std::fs::write(&source_path, "export function f(){}").unwrap();
6254
6255        let coverage_path = temp.path().join("coverage-final.json");
6256        std::fs::write(
6257            &coverage_path,
6258            serde_json::to_string(&serde_json::json!({
6259                "src/index.ts": {
6260                    "path": "src/index.ts",
6261                    "statementMap": {},
6262                    "fnMap": {
6263                        "0": {
6264                            "name": "f",
6265                            "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6266                            "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6267                        }
6268                    },
6269                    "branchMap": {},
6270                    "s": {},
6271                    "f": { "0": 2 },
6272                    "b": {}
6273                }
6274            }))
6275            .unwrap(),
6276        )
6277        .unwrap();
6278
6279        let coverage =
6280            load_istanbul_coverage(&coverage_path, None, Some(temp.path()), false).unwrap();
6281        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6282        let file_coverage = coverage.get(&canonical_source).unwrap();
6283
6284        assert_eq!(file_coverage.lookup("f", 1, 0), Some(100.0));
6285    }
6286
6287    #[test]
6288    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
6289        let temp = tempfile::TempDir::new().unwrap();
6290        let source_path = temp.path().join("src/index.ts");
6291        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6292        std::fs::write(&source_path, "export function f(){}").unwrap();
6293
6294        let coverage_path = temp.path().join("coverage/coverage-final.json");
6295        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
6296        write_single_file_istanbul_fixture(
6297            &coverage_path,
6298            &source_path,
6299            &serde_json::json!({
6300                "0": {
6301                    "name": "f",
6302                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6303                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6304                }
6305            }),
6306            &serde_json::json!({ "0": 1 }),
6307        );
6308
6309        let coverage = load_istanbul_coverage(
6310            std::path::Path::new("coverage/coverage-final.json"),
6311            None,
6312            Some(temp.path()),
6313            false,
6314        )
6315        .expect("relative path must resolve against project_root");
6316        assert!(
6317            !coverage.files.is_empty(),
6318            "expected coverage to load via project_root resolution, got {} files",
6319            coverage.files.len()
6320        );
6321    }
6322
6323    #[test]
6324    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
6325        let temp = tempfile::TempDir::new().unwrap();
6326        let source_path = temp.path().join("src/service.ts");
6327        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6328        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
6329
6330        let coverage_path = temp.path().join("coverage-final.json");
6331        write_single_file_istanbul_fixture(
6332            &coverage_path,
6333            &source_path,
6334            &serde_json::json!({
6335                "0": {
6336                    "name": "(anonymous_0)",
6337                    "decl": {
6338                        "start": { "line": 5, "column": 2 },
6339                        "end": { "line": 5, "column": 13 }
6340                    },
6341                    "loc": {
6342                        "start": { "line": 5, "column": 14 },
6343                        "end": { "line": 11, "column": 3 }
6344                    }
6345                },
6346                "1": {
6347                    "name": "(anonymous_1)",
6348                    "decl": {
6349                        "start": { "line": 20, "column": 14 },
6350                        "end": { "line": 20, "column": 25 }
6351                    },
6352                    "loc": {
6353                        "start": { "line": 20, "column": 28 },
6354                        "end": { "line": 22, "column": 2 }
6355                    }
6356                }
6357            }),
6358            &serde_json::json!({
6359                "0": 1,
6360                "1": 0
6361            }),
6362        );
6363
6364        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6365        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6366        let file_coverage = coverage.get(&canonical_source).unwrap();
6367
6368        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
6369        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
6370    }
6371
6372    #[test]
6373    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
6374        let temp = tempfile::TempDir::new().unwrap();
6375        let source_path = temp.path().join("src/handler.ts");
6376        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6377        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
6378
6379        let coverage_path = temp.path().join("coverage-final.json");
6380        write_single_file_istanbul_fixture(
6381            &coverage_path,
6382            &source_path,
6383            &serde_json::json!({
6384                "0": {
6385                    "name": "handleClick",
6386                    "line": 40,
6387                    "decl": {
6388                        "start": { "line": 22, "column": 13 },
6389                        "end": { "line": 22, "column": 24 }
6390                    },
6391                    "loc": {
6392                        "start": { "line": 40, "column": 27 },
6393                        "end": { "line": 42, "column": 1 }
6394                    }
6395                }
6396            }),
6397            &serde_json::json!({
6398                "0": 1
6399            }),
6400        );
6401
6402        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6403        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6404        let file_coverage = coverage.get(&canonical_source).unwrap();
6405
6406        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
6407        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
6408    }
6409
6410    #[test]
6411    fn load_istanbul_coverage_indexes_valid_body_start_alias() {
6412        let temp = tempfile::TempDir::new().unwrap();
6413        let source_path = temp.path().join("src/handler.ts");
6414        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6415        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
6416
6417        let coverage_path = temp.path().join("coverage-final.json");
6418        write_single_file_istanbul_fixture(
6419            &coverage_path,
6420            &source_path,
6421            &serde_json::json!({
6422                "0": {
6423                    "name": "(anonymous_0)",
6424                    "line": 8,
6425                    "decl": {
6426                        "start": { "line": 8, "column": 14 },
6427                        "end": { "line": 8, "column": 25 }
6428                    },
6429                    "loc": {
6430                        "start": { "line": 20, "column": 6 },
6431                        "end": { "line": 22, "column": 1 }
6432                    }
6433                }
6434            }),
6435            &serde_json::json!({ "0": 1 }),
6436        );
6437
6438        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6439        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6440        let file_coverage = coverage.get(&canonical_source).unwrap();
6441
6442        assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
6443
6444        let mut function = make_fn_complexity(4);
6445        function.name = "handler".to_string();
6446        function.line = 20;
6447        function.col = 6;
6448        let result = istanbul_crap_default(&[function], Some(file_coverage), false);
6449        assert_eq!(result.matched, 1);
6450        assert_eq!(result.total, 1);
6451        assert_eq!(
6452            result.per_function[0].coverage_source,
6453            fallow_output::CoverageSource::Istanbul
6454        );
6455        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6456    }
6457
6458    /// Curried arrows written one per line put each record's body start on
6459    /// the next record's declaration. The declaration is primary and wins the
6460    /// position, so each arrow keeps a record of its own and the innermost one
6461    /// resolves through the header span that opens at the arrow above it.
6462    /// Geometry from istanbul-lib-instrument 6 for the source below, which is
6463    /// what Prettier produces for a curried arrow.
6464    #[test]
6465    fn curried_arrows_one_per_line_each_take_their_own_record() {
6466        let temp = tempfile::TempDir::new().unwrap();
6467        let source_path = temp.path().join("src/adjust.ts");
6468        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6469        std::fs::write(
6470            &source_path,
6471            "export const adjust = (base: number) =>\n  (factor: number) =>\n  (offset: number) =>\n    base * factor + offset;\n",
6472        )
6473        .unwrap();
6474
6475        let coverage_path = temp.path().join("coverage-final.json");
6476        write_single_file_istanbul_fixture(
6477            &coverage_path,
6478            &source_path,
6479            &serde_json::json!({
6480                "0": {
6481                    "name": "(anonymous_0)",
6482                    "line": 2,
6483                    "decl": { "start": { "line": 1, "column": 22 }, "end": { "line": 1, "column": 23 } },
6484                    "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 26 } }
6485                },
6486                "1": {
6487                    "name": "(anonymous_1)",
6488                    "line": 3,
6489                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6490                    "loc": { "start": { "line": 3, "column": 2 }, "end": { "line": 4, "column": 26 } }
6491                },
6492                "2": {
6493                    "name": "(anonymous_2)",
6494                    "line": 4,
6495                    "decl": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 3 } },
6496                    "loc": { "start": { "line": 4, "column": 4 }, "end": { "line": 4, "column": 26 } }
6497                }
6498            }),
6499            &serde_json::json!({ "0": 2, "1": 1, "2": 0 }),
6500        );
6501
6502        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6503        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6504        let file_coverage = coverage.get(&canonical_source).unwrap();
6505
6506        assert_eq!(file_coverage.lookup("adjust", 1, 22), Some(100.0));
6507        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(100.0));
6508        // The innermost arrow never ran, and takes neither neighbour's value.
6509        assert_eq!(file_coverage.lookup("<arrow>", 3, 2), Some(0.0));
6510    }
6511
6512    /// A default value in a parameter list is inside the member's signature,
6513    /// and its own record can be anchored at the parameter rather than at the
6514    /// function, putting the extracted position tens of columns from the
6515    /// declaration. The record still owns that position, because its own
6516    /// signature span covers it. Geometry from an @vitest/coverage-istanbul
6517    /// map for a constructor whose parameter carries a default arrow.
6518    #[test]
6519    fn a_default_value_keeps_its_own_record_inside_the_enclosing_signature() {
6520        let temp = tempfile::TempDir::new().unwrap();
6521        let source_path = temp.path().join("src/filter.ts");
6522        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6523        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6524
6525        let coverage_path = temp.path().join("coverage-final.json");
6526        write_single_file_istanbul_fixture(
6527            &coverage_path,
6528            &source_path,
6529            &serde_json::json!({
6530                "0": {
6531                    "name": "(anonymous_0)",
6532                    "line": 173,
6533                    "decl": { "start": { "line": 169, "column": 2 }, "end": { "line": 170, "column": 3 } },
6534                    "loc": { "start": { "line": 173, "column": 4 }, "end": { "line": 182, "column": 3 } }
6535                },
6536                "1": {
6537                    "name": "(anonymous_1)",
6538                    "line": 171,
6539                    "decl": { "start": { "line": 171, "column": 21 }, "end": { "line": 171, "column": 67 } },
6540                    "loc": { "start": { "line": 171, "column": 67 }, "end": { "line": 171, "column": 76 } }
6541                }
6542            }),
6543            &serde_json::json!({ "0": 46, "1": 0 }),
6544        );
6545
6546        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6547        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6548        let file_coverage = coverage.get(&canonical_source).unwrap();
6549
6550        // Fallow extracts the arrow at its parameter paren, 40 columns right
6551        // of the record's declaration but inside the record's own span.
6552        assert_eq!(file_coverage.lookup("<arrow>", 171, 61), Some(0.0));
6553    }
6554
6555    /// A named function in a signature is a second function inside the
6556    /// member's header span, so the span identifies nothing on its own. A unit
6557    /// with no record of its own, such as a private member or a unit the
6558    /// producer names differently, must take the estimate rather than the
6559    /// coverage of whichever record happens to be near. Geometry from
6560    /// istanbul-lib-instrument 6 for the source below.
6561    #[test]
6562    fn header_span_abstains_when_another_function_is_declared_inside_it() {
6563        let temp = tempfile::TempDir::new().unwrap();
6564        let source_path = temp.path().join("src/chart.ts");
6565        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6566        std::fs::write(
6567            &source_path,
6568            "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",
6569        )
6570        .unwrap();
6571
6572        let coverage_path = temp.path().join("coverage-final.json");
6573        write_single_file_istanbul_fixture(
6574            &coverage_path,
6575            &source_path,
6576            &serde_json::json!({
6577                "0": {
6578                    "name": "(anonymous_0)",
6579                    "line": 10,
6580                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6581                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6582                },
6583                "1": {
6584                    "name": "scale",
6585                    "line": 7,
6586                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 28 } },
6587                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6588                }
6589            }),
6590            &serde_json::json!({ "0": 3, "1": 0 }),
6591        );
6592
6593        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6594        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6595        let coverage = load_istanbul_coverage_for_sources(
6596            &coverage_path,
6597            None,
6598            Some(temp.path()),
6599            Some(&discovered_sources),
6600            false,
6601        )
6602        .unwrap();
6603        let file_coverage = coverage.get(&canonical_source).unwrap();
6604
6605        // Inside both the member's header span and `scale`'s, so neither says
6606        // anything about this position.
6607        assert_eq!(file_coverage.lookup("<anonymous>", 6, 6), None);
6608        // Both records still resolve.
6609        assert_eq!(file_coverage.lookup("scale", 5, 14), Some(0.0));
6610        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6611    }
6612
6613    /// A named function in a member signature can legally reuse the member's
6614    /// name. Name fuzz must not return that inner function before established
6615    /// anonymous resolution preserves the member's valid attribution.
6616    #[test]
6617    fn same_named_function_in_signature_does_not_supply_member_coverage() {
6618        let temp = tempfile::TempDir::new().unwrap();
6619        let source_path = temp.path().join("src/chart.ts");
6620        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6621        std::fs::write(
6622            &source_path,
6623            "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",
6624        )
6625        .unwrap();
6626
6627        let coverage_path = temp.path().join("coverage-final.json");
6628        write_single_file_istanbul_fixture(
6629            &coverage_path,
6630            &source_path,
6631            &serde_json::json!({
6632                "0": {
6633                    "name": "(anonymous_0)",
6634                    "line": 10,
6635                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6636                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6637                },
6638                "1": {
6639                    "name": "render",
6640                    "line": 7,
6641                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 29 } },
6642                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6643                }
6644            }),
6645            &serde_json::json!({ "0": 3, "1": 0 }),
6646        );
6647
6648        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6649        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6650        let file_coverage = coverage.get(&canonical_source).unwrap();
6651
6652        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6653        assert_eq!(file_coverage.lookup("render", 5, 23), Some(0.0));
6654    }
6655
6656    /// A same-line signature can place an unrelated identifier exactly one
6657    /// function-expression prefix away from the member start. Column distance
6658    /// alone must not make that nested function the member's coverage source.
6659    #[test]
6660    fn same_line_same_named_function_in_signature_does_not_supply_member_coverage() {
6661        let temp = tempfile::TempDir::new().unwrap();
6662        let source_path = temp.path().join("src/chart.ts");
6663        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6664        std::fs::write(
6665            &source_path,
6666            "export class Chart {\n  @Watch(\"data\") render(x  = function  render() {}) {}\n}\n",
6667        )
6668        .unwrap();
6669
6670        let coverage_path = temp.path().join("coverage-final.json");
6671        write_single_file_istanbul_fixture(
6672            &coverage_path,
6673            &source_path,
6674            &serde_json::json!({
6675                "0": {
6676                    "name": "(anonymous_0)",
6677                    "line": 2,
6678                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6679                    "loc": { "start": { "line": 2, "column": 52 }, "end": { "line": 2, "column": 54 } }
6680                },
6681                "1": {
6682                    "name": "render",
6683                    "line": 2,
6684                    "decl": { "start": { "line": 2, "column": 39 }, "end": { "line": 2, "column": 45 } },
6685                    "loc": { "start": { "line": 2, "column": 48 }, "end": { "line": 2, "column": 50 } }
6686                }
6687            }),
6688            &serde_json::json!({ "0": 3, "1": 0 }),
6689        );
6690
6691        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6692        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6693        let coverage = load_istanbul_coverage_for_sources(
6694            &coverage_path,
6695            None,
6696            Some(temp.path()),
6697            Some(&discovered_sources),
6698            false,
6699        )
6700        .unwrap();
6701        let file_coverage = coverage.get(&canonical_source).unwrap();
6702
6703        assert_eq!(file_coverage.lookup("render", 2, 23), Some(100.0));
6704        assert_eq!(file_coverage.lookup("render", 2, 29), Some(0.0));
6705    }
6706
6707    #[test]
6708    fn named_generator_alias_handles_trivia_and_utf16_columns() {
6709        let temp = tempfile::TempDir::new().unwrap();
6710        let source_path = temp.path().join("src/chart.ts");
6711        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6712        let source_line =
6713            "  render(label = \"pi: π, mushroom: 🍄\", x = function/* gap */*render() {}) {}";
6714        std::fs::write(
6715            &source_path,
6716            format!("export class Chart {{\n{source_line}\n}}\n"),
6717        )
6718        .unwrap();
6719
6720        let utf16_column = |byte_column: usize| {
6721            u32::try_from(source_line[..byte_column].encode_utf16().count()).unwrap()
6722        };
6723        let outer_target_column = source_line.find("render(").unwrap() + "render".len();
6724        let syntax_column = source_line.find("function").unwrap();
6725        let name_column = source_line.find("*render").unwrap() + 1;
6726        let inner_body_column = source_line.find("{}").unwrap();
6727        let outer_body_column = source_line.rfind("{}").unwrap();
6728
6729        let coverage_path = temp.path().join("coverage-final.json");
6730        write_single_file_istanbul_fixture(
6731            &coverage_path,
6732            &source_path,
6733            &serde_json::json!({
6734                "0": {
6735                    "name": "(anonymous_0)",
6736                    "line": 2,
6737                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6738                    "loc": {
6739                        "start": { "line": 2, "column": utf16_column(outer_body_column) },
6740                        "end": { "line": 2, "column": utf16_column(outer_body_column + 2) }
6741                    }
6742                },
6743                "1": {
6744                    "name": "render",
6745                    "line": 2,
6746                    "decl": {
6747                        "start": { "line": 2, "column": utf16_column(name_column) },
6748                        "end": { "line": 2, "column": utf16_column(name_column + "render".len()) }
6749                    },
6750                    "loc": {
6751                        "start": { "line": 2, "column": utf16_column(inner_body_column) },
6752                        "end": { "line": 2, "column": utf16_column(inner_body_column + 2) }
6753                    }
6754                }
6755            }),
6756            &serde_json::json!({ "0": 3, "1": 0 }),
6757        );
6758
6759        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6760        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6761        let coverage = load_istanbul_coverage_for_sources(
6762            &coverage_path,
6763            None,
6764            Some(temp.path()),
6765            Some(&discovered_sources),
6766            false,
6767        )
6768        .unwrap();
6769        let file_coverage = coverage.get(&canonical_source).unwrap();
6770
6771        assert_eq!(
6772            file_coverage.lookup("render", 2, u32::try_from(outer_target_column).unwrap()),
6773            Some(100.0)
6774        );
6775        assert_eq!(
6776            file_coverage.lookup("render", 2, u32::try_from(syntax_column).unwrap()),
6777            Some(0.0)
6778        );
6779    }
6780
6781    #[test]
6782    fn utf16_index_is_sparse_and_rejects_surrogate_boundaries() {
6783        let ascii_prefix = "a".repeat(4_096);
6784        let source = format!("{ascii_prefix}🍄{}", "b".repeat(4_096));
6785        let index = IstanbulSourceIndex::new(&source, std::path::Path::new("minified.js"));
6786        let line_index = &index.non_ascii_lines[&0];
6787
6788        assert_eq!(line_index.checkpoints.len(), 1);
6789        assert_eq!(
6790            index.byte_position(1, 4_096),
6791            Some(IstanbulPosition::new(1, 4_096))
6792        );
6793        assert_eq!(index.byte_position(1, 4_097), None);
6794        assert_eq!(
6795            index.byte_position(1, 4_098),
6796            Some(IstanbulPosition::new(1, 4_100))
6797        );
6798        assert_eq!(
6799            index.byte_position(1, 8_194),
6800            Some(IstanbulPosition::new(1, 8_196))
6801        );
6802    }
6803
6804    #[test]
6805    fn effective_alias_normalizes_against_its_own_unicode_line() {
6806        let temp = tempfile::TempDir::new().unwrap();
6807        let source_path = temp.path().join("src/render.ts");
6808        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6809        std::fs::write(
6810            &source_path,
6811            "/*🍄*/ const placeholder = 0;\n/*π*/ function render() {}\n",
6812        )
6813        .unwrap();
6814
6815        let coverage_path = temp.path().join("coverage-final.json");
6816        write_single_file_istanbul_fixture(
6817            &coverage_path,
6818            &source_path,
6819            &serde_json::json!({
6820                "0": {
6821                    "name": "render",
6822                    "line": 1,
6823                    "decl": { "start": { "line": 2, "column": 15 }, "end": { "line": 2, "column": 21 } },
6824                    "loc": { "start": { "line": 2, "column": 24 }, "end": { "line": 2, "column": 26 } }
6825                }
6826            }),
6827            &serde_json::json!({ "0": 1 }),
6828        );
6829
6830        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6831        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6832        let coverage = load_istanbul_coverage_for_sources(
6833            &coverage_path,
6834            None,
6835            Some(temp.path()),
6836            Some(&discovered_sources),
6837            false,
6838        )
6839        .unwrap();
6840        let function = &coverage.get(&canonical_source).unwrap().functions[0];
6841
6842        assert!(
6843            function
6844                .aliases
6845                .iter()
6846                .any(|alias| { alias.position == IstanbulPosition::new(1, 17) && alias.primary })
6847        );
6848        assert!(
6849            !function
6850                .aliases
6851                .iter()
6852                .any(|alias| alias.position == IstanbulPosition::new(1, 16))
6853        );
6854    }
6855
6856    #[test]
6857    fn stale_utf16_coordinates_are_rejected_with_trusted_source() {
6858        let temp = tempfile::TempDir::new().unwrap();
6859        let source_path = temp.path().join("src/render.ts");
6860        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6861        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6862
6863        let coverage_path = temp.path().join("coverage-final.json");
6864        write_single_file_istanbul_fixture(
6865            &coverage_path,
6866            &source_path,
6867            &serde_json::json!({
6868                "0": {
6869                    "name": "render",
6870                    "line": 1,
6871                    "decl": { "start": { "line": 1, "column": 999 }, "end": { "line": 1, "column": 22 } },
6872                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6873                }
6874            }),
6875            &serde_json::json!({ "0": 1 }),
6876        );
6877
6878        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6879        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6880        let coverage = load_istanbul_coverage_for_sources(
6881            &coverage_path,
6882            None,
6883            Some(temp.path()),
6884            Some(&discovered_sources),
6885            false,
6886        )
6887        .unwrap();
6888
6889        assert_eq!(
6890            coverage
6891                .get(&canonical_source)
6892                .unwrap()
6893                .lookup("render", 1, 7),
6894            None
6895        );
6896    }
6897
6898    #[test]
6899    fn invalid_optional_coordinates_preserve_valid_declaration_attribution() {
6900        let temp = tempfile::TempDir::new().unwrap();
6901        let source_path = temp.path().join("src/render.ts");
6902        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6903        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6904
6905        let coverage_path = temp.path().join("coverage-final.json");
6906        write_single_file_istanbul_fixture(
6907            &coverage_path,
6908            &source_path,
6909            &serde_json::json!({
6910                "0": {
6911                    "name": "render",
6912                    "line": 99,
6913                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 99, "column": 999 } },
6914                    "loc": { "start": { "line": 1, "column": 999 }, "end": { "line": 99, "column": 999 } }
6915                }
6916            }),
6917            &serde_json::json!({ "0": 1 }),
6918        );
6919
6920        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6921        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6922        let coverage = load_istanbul_coverage_for_sources(
6923            &coverage_path,
6924            None,
6925            Some(temp.path()),
6926            Some(&discovered_sources),
6927            false,
6928        )
6929        .unwrap();
6930        let file_coverage = coverage.get(&canonical_source).unwrap();
6931        let function = &file_coverage.functions[0];
6932
6933        assert_eq!(file_coverage.lookup("render", 1, 16), Some(100.0));
6934        assert!(function.body_span.is_none());
6935        assert!(function.header_span.is_none());
6936        assert!(
6937            !function
6938                .aliases
6939                .iter()
6940                .any(|alias| { alias.position.line == 99 || alias.position.col == 999 })
6941        );
6942    }
6943
6944    #[test]
6945    fn malformed_source_does_not_supply_named_function_provenance() {
6946        assert!(
6947            !IstanbulSourceIndex::new(
6948                "export function render() {}",
6949                std::path::Path::new("valid.ts"),
6950            )
6951            .named_function_starts
6952            .is_empty()
6953        );
6954        let index = IstanbulSourceIndex::new(
6955            "export function render() {} const broken = ;",
6956            std::path::Path::new("broken.ts"),
6957        );
6958
6959        assert!(index.named_function_starts.is_empty());
6960    }
6961
6962    #[test]
6963    fn javascript_with_jsx_uses_clean_jsx_provenance_parse() {
6964        let index = IstanbulSourceIndex::new(
6965            "export function render() { return <div />; }",
6966            std::path::Path::new("component.js"),
6967        );
6968
6969        assert!(!index.named_function_starts.is_empty());
6970    }
6971
6972    #[test]
6973    fn undiscovered_coverage_path_is_not_loaded_for_source_provenance() {
6974        let temp = tempfile::TempDir::new().unwrap();
6975        let source_path = temp.path().join("src/excluded.ts");
6976        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6977        std::fs::write(&source_path, "export function render() {}\n").unwrap();
6978
6979        let coverage_path = temp.path().join("coverage-final.json");
6980        write_single_file_istanbul_fixture(
6981            &coverage_path,
6982            &source_path,
6983            &serde_json::json!({
6984                "0": {
6985                    "name": "render",
6986                    "line": 1,
6987                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 22 } },
6988                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6989                }
6990            }),
6991            &serde_json::json!({ "0": 1 }),
6992        );
6993
6994        let coverage = load_istanbul_coverage_for_sources(
6995            &coverage_path,
6996            None,
6997            Some(temp.path()),
6998            Some(&rustc_hash::FxHashSet::default()),
6999            false,
7000        )
7001        .unwrap();
7002        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7003        let function = &coverage.get(&canonical_source).unwrap().functions[0];
7004
7005        assert!(
7006            function
7007                .aliases
7008                .iter()
7009                .all(|alias| alias.position != IstanbulPosition::new(1, 7))
7010        );
7011    }
7012
7013    /// No instrumenter emits an `fnMap` identity for a private class member,
7014    /// so any candidate one reaches belongs to an enclosing function.
7015    #[test]
7016    fn private_class_member_never_takes_enclosing_coverage() {
7017        let temp = tempfile::TempDir::new().unwrap();
7018        let source_path = temp.path().join("src/vault.js");
7019        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7020        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7021
7022        let coverage_path = temp.path().join("coverage-final.json");
7023        write_single_file_istanbul_fixture(
7024            &coverage_path,
7025            &source_path,
7026            &serde_json::json!({
7027                "0": {
7028                    "name": "(anonymous_0)",
7029                    "line": 7,
7030                    "decl": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 25 } },
7031                    "loc": { "start": { "line": 7, "column": 5 }, "end": { "line": 7, "column": 20 } }
7032                }
7033            }),
7034            &serde_json::json!({ "0": 1 }),
7035        );
7036
7037        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7038        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7039        let file_coverage = coverage.get(&canonical_source).unwrap();
7040
7041        // `#wipe` sits in the arrow's header span and the arrow ran, but the
7042        // private member has no record of its own and never ran.
7043        let mut private_member = make_fn_complexity(1);
7044        private_member.name = "#wipe".to_string();
7045        private_member.is_private_member = true;
7046        private_member.line = 3;
7047        private_member.col = 9;
7048        let result = istanbul_crap_default(&[private_member], Some(file_coverage), false);
7049        assert_eq!(result.matched, 0);
7050        assert_eq!(
7051            result.per_function[0].coverage_source,
7052            fallow_output::CoverageSource::Estimated
7053        );
7054        assert_eq!(file_coverage.lookup("<arrow>", 1, 24), Some(100.0));
7055    }
7056
7057    #[test]
7058    fn quoted_hash_method_keeps_exact_istanbul_coverage() {
7059        let temp = tempfile::TempDir::new().unwrap();
7060        let source_path = temp.path().join("src/vault.js");
7061        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7062        std::fs::write(&source_path, "class Vault { '#wipe'() {} }\n").unwrap();
7063
7064        let coverage_path = temp.path().join("coverage-final.json");
7065        write_single_file_istanbul_fixture(
7066            &coverage_path,
7067            &source_path,
7068            &serde_json::json!({
7069                "0": {
7070                    "name": "#wipe",
7071                    "line": 1,
7072                    "decl": { "start": { "line": 1, "column": 14 }, "end": { "line": 1, "column": 21 } },
7073                    "loc": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 26 } }
7074                }
7075            }),
7076            &serde_json::json!({ "0": 1 }),
7077        );
7078
7079        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7080        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7081        let file_coverage = coverage.get(&canonical_source).unwrap();
7082
7083        let mut quoted_public_method = make_fn_complexity(1);
7084        quoted_public_method.name = "#wipe".to_string();
7085        quoted_public_method.line = 1;
7086        quoted_public_method.col = 14;
7087        let result = istanbul_crap_default(&[quoted_public_method], Some(file_coverage), false);
7088        assert_eq!(result.matched, 1);
7089        assert_eq!(
7090            result.per_function[0].coverage_source,
7091            fallow_output::CoverageSource::Istanbul
7092        );
7093        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
7094    }
7095
7096    /// A decorated member's `decl` opens at the decorator and its `loc` opens
7097    /// at the body brace, so the extracted position sits between them with no
7098    /// alias in reach: the decorator is more than
7099    /// `ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT` columns to the left and the body
7100    /// is more than `ALIAS_FUZZ_MAX_LINE_DRIFT` lines below. The header span
7101    /// is what identifies the member. Geometry from istanbul-lib-instrument 6
7102    /// for the source below, which is ordinary NestJS, Angular, and TypeORM
7103    /// shape rather than a corner case.
7104    #[test]
7105    fn decorated_member_matches_its_istanbul_header_span() {
7106        let temp = tempfile::TempDir::new().unwrap();
7107        let source_path = temp.path().join("src/users.controller.ts");
7108        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7109        std::fs::write(
7110            &source_path,
7111            "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",
7112        )
7113        .unwrap();
7114
7115        let coverage_path = temp.path().join("coverage-final.json");
7116        write_single_file_istanbul_fixture(
7117            &coverage_path,
7118            &source_path,
7119            &serde_json::json!({
7120                "0": {
7121                    "name": "(anonymous_0)",
7122                    "line": 6,
7123                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7124                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7125                }
7126            }),
7127            &serde_json::json!({ "0": 3 }),
7128        );
7129
7130        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7131        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7132        let file_coverage = coverage.get(&canonical_source).unwrap();
7133
7134        // Fallow extracts the member at the parameter paren, 3:26.
7135        assert_eq!(
7136            file_coverage.lookup("findOneWithProfile", 3, 26),
7137            Some(100.0)
7138        );
7139    }
7140
7141    /// A function written in a signature has a record of its own, and the
7142    /// signature it sits in belongs to a different function. The record wins:
7143    /// crediting the enclosing member would report the member's coverage for
7144    /// a default value that never ran. Geometry from istanbul-lib-instrument 6
7145    /// for the source below.
7146    #[test]
7147    fn established_alias_wins_over_the_signature_that_contains_it() {
7148        let temp = tempfile::TempDir::new().unwrap();
7149        let source_path = temp.path().join("src/chart.ts");
7150        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7151        std::fs::write(
7152            &source_path,
7153            "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",
7154        )
7155        .unwrap();
7156
7157        let coverage_path = temp.path().join("coverage-final.json");
7158        write_single_file_istanbul_fixture(
7159            &coverage_path,
7160            &source_path,
7161            &serde_json::json!({
7162                "0": {
7163                    "name": "(anonymous_0)",
7164                    "line": 6,
7165                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7166                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7167                },
7168                "1": {
7169                    "name": "(anonymous_1)",
7170                    "line": 5,
7171                    "decl": { "start": { "line": 5, "column": 14 }, "end": { "line": 5, "column": 15 } },
7172                    "loc": { "start": { "line": 5, "column": 31 }, "end": { "line": 5, "column": 38 } }
7173                }
7174            }),
7175            &serde_json::json!({ "0": 3, "1": 0 }),
7176        );
7177
7178        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7179        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7180        let file_coverage = coverage.get(&canonical_source).unwrap();
7181
7182        // The default value takes its own record, not the method's 100.
7183        assert_eq!(file_coverage.lookup("<arrow>", 5, 14), Some(0.0));
7184        // The method still resolves, and not to its default value.
7185        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7186    }
7187
7188    /// A member whose signature holds a function of its own has two records a
7189    /// line or two apart, and the member's extracted position is closer to
7190    /// the inner one. Proximity would report the default value's coverage for
7191    /// the member, and the header span cannot break the tie because it
7192    /// contains both. Abstaining leaves the static estimate, which is wrong by
7193    /// a known amount rather than wrong while claiming to be measured.
7194    /// Geometry from istanbul-lib-instrument 6 for the source below.
7195    #[test]
7196    fn signature_holding_a_function_abstains_instead_of_crediting_it() {
7197        let temp = tempfile::TempDir::new().unwrap();
7198        let source_path = temp.path().join("src/users.controller.ts");
7199        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7200        std::fs::write(
7201            &source_path,
7202            "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",
7203        )
7204        .unwrap();
7205
7206        let coverage_path = temp.path().join("coverage-final.json");
7207        write_single_file_istanbul_fixture(
7208            &coverage_path,
7209            &source_path,
7210            &serde_json::json!({
7211                "0": {
7212                    "name": "(anonymous_0)",
7213                    "line": 6,
7214                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7215                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7216                },
7217                "1": {
7218                    "name": "(anonymous_1)",
7219                    "line": 5,
7220                    "decl": { "start": { "line": 5, "column": 16 }, "end": { "line": 5, "column": 17 } },
7221                    "loc": { "start": { "line": 5, "column": 33 }, "end": { "line": 5, "column": 43 } }
7222                }
7223            }),
7224            &serde_json::json!({ "0": 3, "1": 0 }),
7225        );
7226
7227        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7228        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7229        let file_coverage = coverage.get(&canonical_source).unwrap();
7230
7231        // The member is two lines from the default value's declaration and
7232        // ten columns off it, well inside the fallback's reach.
7233        assert_eq!(file_coverage.lookup("findOneWithProfile", 3, 26), None);
7234        // The default value itself still resolves.
7235        assert_eq!(file_coverage.lookup("<arrow>", 5, 16), Some(0.0));
7236    }
7237
7238    #[test]
7239    fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
7240        let temp = tempfile::TempDir::new().unwrap();
7241        let source_path = temp.path().join("src/aliases.ts");
7242        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7243        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7244
7245        let coverage_path = temp.path().join("coverage-final.json");
7246        write_single_file_istanbul_fixture(
7247            &coverage_path,
7248            &source_path,
7249            &serde_json::json!({
7250                "0": {
7251                    "name": "(anonymous_0)",
7252                    "line": 10,
7253                    "decl": {
7254                        "start": { "line": 10, "column": 8 },
7255                        "end": { "line": 10, "column": 9 }
7256                    },
7257                    "loc": {
7258                        "start": { "line": 12, "column": 8 },
7259                        "end": { "line": 13, "column": 1 }
7260                    }
7261                }
7262            }),
7263            &serde_json::json!({ "0": 1 }),
7264        );
7265
7266        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7267        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7268        let file_coverage = coverage.get(&canonical_source).unwrap();
7269
7270        assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
7271    }
7272
7273    /// istanbul-lib-instrument geometry for
7274    /// `export const nested = () => () => true;`: the outer arrow's `loc` is
7275    /// its expression body, which starts exactly where the inner arrow is
7276    /// declared.
7277    #[test]
7278    fn curried_arrow_one_liner_resolves_both_arrows() {
7279        let temp = tempfile::TempDir::new().unwrap();
7280        let source_path = temp.path().join("src/nested.ts");
7281        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7282        std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
7283
7284        let coverage_path = temp.path().join("coverage-final.json");
7285        write_single_file_istanbul_fixture(
7286            &coverage_path,
7287            &source_path,
7288            &serde_json::json!({
7289                "0": {
7290                    "name": "(anonymous_0)",
7291                    "line": 1,
7292                    "decl": {
7293                        "start": { "line": 1, "column": 22 },
7294                        "end": { "line": 1, "column": 23 }
7295                    },
7296                    "loc": {
7297                        "start": { "line": 1, "column": 28 },
7298                        "end": { "line": 1, "column": 38 }
7299                    }
7300                },
7301                "1": {
7302                    "name": "(anonymous_1)",
7303                    "line": 1,
7304                    "decl": {
7305                        "start": { "line": 1, "column": 28 },
7306                        "end": { "line": 1, "column": 29 }
7307                    },
7308                    "loc": {
7309                        "start": { "line": 1, "column": 34 },
7310                        "end": { "line": 1, "column": 38 }
7311                    }
7312                }
7313            }),
7314            &serde_json::json!({ "0": 1, "1": 0 }),
7315        );
7316
7317        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7318        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7319        let file_coverage = coverage.get(&canonical_source).unwrap();
7320
7321        assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
7322        assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
7323    }
7324
7325    /// A nested function body sits inside its enclosing function's `loc`, so
7326    /// counting every contained statement charged the inner body's misses to
7327    /// the outer record as well.
7328    ///
7329    /// Geometry recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7330    ///
7331    /// ```js
7332    /// function outer() {
7333    ///   return function inner(flag) {
7334    ///     if (flag) return "yes";
7335    ///     return "no";
7336    ///   };
7337    /// }
7338    ///
7339    /// module.exports = { outer };
7340    /// ```
7341    ///
7342    /// with `outer()` called once and `inner` never called. Statement 0 is
7343    /// the outer return and is hit, statements 1 to 3 are the inner body and
7344    /// are not, statement 4 is the module-scope export.
7345    #[test]
7346    fn nested_function_statements_do_not_lower_the_outer_function() {
7347        let temp = tempfile::TempDir::new().unwrap();
7348        let source_path = temp.path().join("src/nested.js");
7349        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7350        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7351
7352        let coverage_path = temp.path().join("coverage-final.json");
7353        write_single_file_istanbul_fixture_with_statements(
7354            &coverage_path,
7355            &source_path,
7356            &nested_return_fn_map(),
7357            &serde_json::json!({ "0": 1, "1": 0 }),
7358            &nested_return_statement_map(),
7359            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 1 }),
7360        );
7361
7362        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7363        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7364        let file_coverage = coverage.get(&canonical_source).unwrap();
7365
7366        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7367        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7368    }
7369
7370    /// A statement outside every `fnMap` record belongs to no function. The
7371    /// module-scope export in the fixture above is hit, and flipping it to
7372    /// unhit must not move either function.
7373    #[test]
7374    fn module_scope_statements_belong_to_no_function() {
7375        let temp = tempfile::TempDir::new().unwrap();
7376        let source_path = temp.path().join("src/nested.js");
7377        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7378        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7379
7380        let coverage_path = temp.path().join("coverage-final.json");
7381        write_single_file_istanbul_fixture_with_statements(
7382            &coverage_path,
7383            &source_path,
7384            &nested_return_fn_map(),
7385            &serde_json::json!({ "0": 1, "1": 0 }),
7386            &nested_return_statement_map(),
7387            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 0 }),
7388        );
7389
7390        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7391        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7392        let file_coverage = coverage.get(&canonical_source).unwrap();
7393
7394        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7395        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7396    }
7397
7398    /// Ownership can leave a record with no statements of its own, and that
7399    /// record still has to fall back to the function hit count. Geometry
7400    /// recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7401    ///
7402    /// ```js
7403    /// function outer() {
7404    ///   function inner(flag) {
7405    ///     if (flag) return "yes";
7406    ///     return "no";
7407    ///   }
7408    /// }
7409    ///
7410    /// module.exports = { outer };
7411    /// ```
7412    ///
7413    /// with `outer()` called once. A hoisted declaration is not a statement,
7414    /// so every statement inside `outer` belongs to `inner`.
7415    #[test]
7416    fn an_outer_function_with_only_a_nested_body_falls_back_to_the_hit_count() {
7417        let temp = tempfile::TempDir::new().unwrap();
7418        let source_path = temp.path().join("src/decl-nested.js");
7419        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7420        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7421
7422        let coverage_path = temp.path().join("coverage-final.json");
7423        write_single_file_istanbul_fixture_with_statements(
7424            &coverage_path,
7425            &source_path,
7426            &serde_json::json!({
7427                "0": {
7428                    "name": "outer",
7429                    "line": 1,
7430                    "decl": {
7431                        "start": { "line": 1, "column": 9 },
7432                        "end": { "line": 1, "column": 14 }
7433                    },
7434                    "loc": {
7435                        "start": { "line": 1, "column": 17 },
7436                        "end": { "line": 6, "column": 1 }
7437                    }
7438                },
7439                "1": {
7440                    "name": "inner",
7441                    "line": 2,
7442                    "decl": {
7443                        "start": { "line": 2, "column": 11 },
7444                        "end": { "line": 2, "column": 16 }
7445                    },
7446                    "loc": {
7447                        "start": { "line": 2, "column": 23 },
7448                        "end": { "line": 5, "column": 3 }
7449                    }
7450                }
7451            }),
7452            &serde_json::json!({ "0": 1, "1": 0 }),
7453            &serde_json::json!({
7454                "0": {
7455                    "start": { "line": 3, "column": 4 },
7456                    "end": { "line": 3, "column": 27 }
7457                },
7458                "1": {
7459                    "start": { "line": 3, "column": 14 },
7460                    "end": { "line": 3, "column": 27 }
7461                },
7462                "2": {
7463                    "start": { "line": 4, "column": 4 },
7464                    "end": { "line": 4, "column": 16 }
7465                },
7466                "3": {
7467                    "start": { "line": 8, "column": 0 },
7468                    "end": { "line": 8, "column": 27 }
7469                }
7470            }),
7471            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1 }),
7472        );
7473
7474        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7475        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7476        let file_coverage = coverage.get(&canonical_source).unwrap();
7477
7478        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7479        assert_eq!(file_coverage.lookup("inner", 2, 11), Some(0.0));
7480    }
7481
7482    /// Two functions at module scope never contain each other, so ownership
7483    /// must leave them exactly where they were. Geometry recorded verbatim
7484    /// from istanbul-lib-instrument 6.0.3 for the same source with `inner`
7485    /// hoisted out of `outer`.
7486    #[test]
7487    fn sibling_functions_keep_independent_statement_coverage() {
7488        let temp = tempfile::TempDir::new().unwrap();
7489        let source_path = temp.path().join("src/sibling.js");
7490        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7491        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7492
7493        let coverage_path = temp.path().join("coverage-final.json");
7494        write_single_file_istanbul_fixture_with_statements(
7495            &coverage_path,
7496            &source_path,
7497            &serde_json::json!({
7498                "0": {
7499                    "name": "inner",
7500                    "line": 1,
7501                    "decl": {
7502                        "start": { "line": 1, "column": 9 },
7503                        "end": { "line": 1, "column": 14 }
7504                    },
7505                    "loc": {
7506                        "start": { "line": 1, "column": 21 },
7507                        "end": { "line": 4, "column": 1 }
7508                    }
7509                },
7510                "1": {
7511                    "name": "outer",
7512                    "line": 6,
7513                    "decl": {
7514                        "start": { "line": 6, "column": 9 },
7515                        "end": { "line": 6, "column": 14 }
7516                    },
7517                    "loc": {
7518                        "start": { "line": 6, "column": 17 },
7519                        "end": { "line": 8, "column": 1 }
7520                    }
7521                }
7522            }),
7523            &serde_json::json!({ "0": 0, "1": 1 }),
7524            &serde_json::json!({
7525                "0": {
7526                    "start": { "line": 2, "column": 2 },
7527                    "end": { "line": 2, "column": 25 }
7528                },
7529                "1": {
7530                    "start": { "line": 2, "column": 12 },
7531                    "end": { "line": 2, "column": 25 }
7532                },
7533                "2": {
7534                    "start": { "line": 3, "column": 2 },
7535                    "end": { "line": 3, "column": 14 }
7536                },
7537                "3": {
7538                    "start": { "line": 7, "column": 2 },
7539                    "end": { "line": 7, "column": 15 }
7540                },
7541                "4": {
7542                    "start": { "line": 10, "column": 0 },
7543                    "end": { "line": 10, "column": 27 }
7544                }
7545            }),
7546            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1, "4": 1 }),
7547        );
7548
7549        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7550        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7551        let file_coverage = coverage.get(&canonical_source).unwrap();
7552
7553        assert_eq!(file_coverage.lookup("outer", 6, 9), Some(100.0));
7554        assert_eq!(file_coverage.lookup("inner", 1, 9), Some(0.0));
7555    }
7556
7557    /// Curried arrows share their closing position, so the inner body is
7558    /// distinguished only by its later start. Geometry recorded verbatim from
7559    /// istanbul-lib-instrument 6.0.3 for `const add = (a) => (b) => a + b;`
7560    /// with `add(1)` called once, so the outer arrow ran and the inner did
7561    /// not. Statement 1 is the outer expression body, statement 2 the inner.
7562    #[test]
7563    fn curried_arrow_statements_belong_to_the_innermost_arrow() {
7564        let temp = tempfile::TempDir::new().unwrap();
7565        let source_path = temp.path().join("src/curried.js");
7566        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7567        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7568
7569        let coverage_path = temp.path().join("coverage-final.json");
7570        write_single_file_istanbul_fixture_with_statements(
7571            &coverage_path,
7572            &source_path,
7573            &serde_json::json!({
7574                "0": {
7575                    "name": "(anonymous_0)",
7576                    "line": 1,
7577                    "decl": {
7578                        "start": { "line": 1, "column": 12 },
7579                        "end": { "line": 1, "column": 13 }
7580                    },
7581                    "loc": {
7582                        "start": { "line": 1, "column": 19 },
7583                        "end": { "line": 1, "column": 31 }
7584                    }
7585                },
7586                "1": {
7587                    "name": "(anonymous_1)",
7588                    "line": 1,
7589                    "decl": {
7590                        "start": { "line": 1, "column": 19 },
7591                        "end": { "line": 1, "column": 20 }
7592                    },
7593                    "loc": {
7594                        "start": { "line": 1, "column": 26 },
7595                        "end": { "line": 1, "column": 31 }
7596                    }
7597                }
7598            }),
7599            &serde_json::json!({ "0": 1, "1": 0 }),
7600            &serde_json::json!({
7601                "0": {
7602                    "start": { "line": 1, "column": 12 },
7603                    "end": { "line": 1, "column": 31 }
7604                },
7605                "1": {
7606                    "start": { "line": 1, "column": 19 },
7607                    "end": { "line": 1, "column": 31 }
7608                },
7609                "2": {
7610                    "start": { "line": 1, "column": 26 },
7611                    "end": { "line": 1, "column": 31 }
7612                },
7613                "3": {
7614                    "start": { "line": 3, "column": 0 },
7615                    "end": { "line": 3, "column": 25 }
7616                }
7617            }),
7618            &serde_json::json!({ "0": 1, "1": 1, "2": 0, "3": 1 }),
7619        );
7620
7621        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7622        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7623        let file_coverage = coverage.get(&canonical_source).unwrap();
7624
7625        assert_eq!(file_coverage.lookup("add", 1, 12), Some(100.0));
7626        assert_eq!(file_coverage.lookup("<arrow>", 1, 19), Some(0.0));
7627    }
7628
7629    /// The `fnMap` and `statementMap` istanbul-lib-instrument 6.0.3 records
7630    /// for the nested-return source, shared by the two tests that pin
7631    /// statement ownership against it.
7632    fn nested_return_fn_map() -> serde_json::Value {
7633        serde_json::json!({
7634            "0": {
7635                "name": "outer",
7636                "line": 1,
7637                "decl": {
7638                    "start": { "line": 1, "column": 9 },
7639                    "end": { "line": 1, "column": 14 }
7640                },
7641                "loc": {
7642                    "start": { "line": 1, "column": 17 },
7643                    "end": { "line": 6, "column": 1 }
7644                }
7645            },
7646            "1": {
7647                "name": "inner",
7648                "line": 2,
7649                "decl": {
7650                    "start": { "line": 2, "column": 18 },
7651                    "end": { "line": 2, "column": 23 }
7652                },
7653                "loc": {
7654                    "start": { "line": 2, "column": 30 },
7655                    "end": { "line": 5, "column": 3 }
7656                }
7657            }
7658        })
7659    }
7660
7661    fn nested_return_statement_map() -> serde_json::Value {
7662        serde_json::json!({
7663            "0": {
7664                "start": { "line": 2, "column": 2 },
7665                "end": { "line": 5, "column": 4 }
7666            },
7667            "1": {
7668                "start": { "line": 3, "column": 4 },
7669                "end": { "line": 3, "column": 27 }
7670            },
7671            "2": {
7672                "start": { "line": 3, "column": 14 },
7673                "end": { "line": 3, "column": 27 }
7674            },
7675            "3": {
7676                "start": { "line": 4, "column": 4 },
7677                "end": { "line": 4, "column": 16 }
7678            },
7679            "4": {
7680                "start": { "line": 8, "column": 0 },
7681                "end": { "line": 8, "column": 27 }
7682            }
7683        })
7684    }
7685
7686    /// istanbul-lib-instrument geometry for a multi-line higher-order
7687    /// component. The producer's `line` is the body start line, so the outer
7688    /// record also carries an effective alias on line 2.
7689    #[test]
7690    fn curried_arrow_multiline_hoc_resolves_both_arrows() {
7691        let temp = tempfile::TempDir::new().unwrap();
7692        let source_path = temp.path().join("src/with-auth.tsx");
7693        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7694        std::fs::write(
7695            &source_path,
7696            "export const withAuth = (Component) =>\n  (props) => {\n    return Component(props);\n  };\n",
7697        )
7698        .unwrap();
7699
7700        let coverage_path = temp.path().join("coverage-final.json");
7701        write_single_file_istanbul_fixture(
7702            &coverage_path,
7703            &source_path,
7704            &serde_json::json!({
7705                "0": {
7706                    "name": "(anonymous_0)",
7707                    "line": 2,
7708                    "decl": {
7709                        "start": { "line": 1, "column": 24 },
7710                        "end": { "line": 1, "column": 25 }
7711                    },
7712                    "loc": {
7713                        "start": { "line": 2, "column": 2 },
7714                        "end": { "line": 4, "column": 3 }
7715                    }
7716                },
7717                "1": {
7718                    "name": "(anonymous_1)",
7719                    "line": 2,
7720                    "decl": {
7721                        "start": { "line": 2, "column": 2 },
7722                        "end": { "line": 2, "column": 3 }
7723                    },
7724                    "loc": {
7725                        "start": { "line": 2, "column": 13 },
7726                        "end": { "line": 4, "column": 3 }
7727                    }
7728                }
7729            }),
7730            &serde_json::json!({ "0": 1, "1": 0 }),
7731        );
7732
7733        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7734        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7735        let file_coverage = coverage.get(&canonical_source).unwrap();
7736
7737        assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
7738        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
7739    }
7740
7741    /// istanbul-lib-instrument geometry for a depth-3 redux middleware chain.
7742    /// Every non-first arrow is declared where the previous body starts.
7743    #[test]
7744    fn curried_arrow_depth_three_chain_resolves_every_arrow() {
7745        let temp = tempfile::TempDir::new().unwrap();
7746        let source_path = temp.path().join("src/logger.ts");
7747        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7748        std::fs::write(
7749            &source_path,
7750            "export const logger = (store) => (next) => (action) => {\n  return next(action);\n};\n",
7751        )
7752        .unwrap();
7753
7754        let coverage_path = temp.path().join("coverage-final.json");
7755        write_single_file_istanbul_fixture(
7756            &coverage_path,
7757            &source_path,
7758            &serde_json::json!({
7759                "0": {
7760                    "name": "(anonymous_0)",
7761                    "line": 1,
7762                    "decl": {
7763                        "start": { "line": 1, "column": 22 },
7764                        "end": { "line": 1, "column": 23 }
7765                    },
7766                    "loc": {
7767                        "start": { "line": 1, "column": 33 },
7768                        "end": { "line": 3, "column": 1 }
7769                    }
7770                },
7771                "1": {
7772                    "name": "(anonymous_1)",
7773                    "line": 1,
7774                    "decl": {
7775                        "start": { "line": 1, "column": 33 },
7776                        "end": { "line": 1, "column": 34 }
7777                    },
7778                    "loc": {
7779                        "start": { "line": 1, "column": 43 },
7780                        "end": { "line": 3, "column": 1 }
7781                    }
7782                },
7783                "2": {
7784                    "name": "(anonymous_2)",
7785                    "line": 1,
7786                    "decl": {
7787                        "start": { "line": 1, "column": 43 },
7788                        "end": { "line": 1, "column": 44 }
7789                    },
7790                    "loc": {
7791                        "start": { "line": 1, "column": 55 },
7792                        "end": { "line": 3, "column": 1 }
7793                    }
7794                }
7795            }),
7796            &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
7797        );
7798
7799        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7800        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7801        let file_coverage = coverage.get(&canonical_source).unwrap();
7802
7803        assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
7804        assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
7805        assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
7806    }
7807
7808    /// istanbul-lib-instrument geometry for a curried class-property arrow.
7809    #[test]
7810    fn curried_class_property_arrow_resolves_both_arrows() {
7811        let temp = tempfile::TempDir::new().unwrap();
7812        let source_path = temp.path().join("src/store.ts");
7813        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7814        std::fs::write(
7815            &source_path,
7816            "export class Store {\n  handle = (event) => (payload) => {\n    return payload;\n  };\n}\n",
7817        )
7818        .unwrap();
7819
7820        let coverage_path = temp.path().join("coverage-final.json");
7821        write_single_file_istanbul_fixture(
7822            &coverage_path,
7823            &source_path,
7824            &serde_json::json!({
7825                "0": {
7826                    "name": "(anonymous_0)",
7827                    "line": 2,
7828                    "decl": {
7829                        "start": { "line": 2, "column": 11 },
7830                        "end": { "line": 2, "column": 12 }
7831                    },
7832                    "loc": {
7833                        "start": { "line": 2, "column": 22 },
7834                        "end": { "line": 4, "column": 3 }
7835                    }
7836                },
7837                "1": {
7838                    "name": "(anonymous_1)",
7839                    "line": 2,
7840                    "decl": {
7841                        "start": { "line": 2, "column": 22 },
7842                        "end": { "line": 2, "column": 23 }
7843                    },
7844                    "loc": {
7845                        "start": { "line": 2, "column": 35 },
7846                        "end": { "line": 4, "column": 3 }
7847                    }
7848                }
7849            }),
7850            &serde_json::json!({ "0": 1, "1": 0 }),
7851        );
7852
7853        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7854        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7855        let file_coverage = coverage.get(&canonical_source).unwrap();
7856
7857        assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
7858        assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
7859    }
7860
7861    /// istanbul-lib-instrument geometry for two sibling arrows in an object
7862    /// literal. A target one line away from both, at the shared column, ties
7863    /// on distance and lies in neither body, so the lookup abstains.
7864    #[test]
7865    fn anonymous_sibling_tie_outside_every_body_abstains() {
7866        let temp = tempfile::TempDir::new().unwrap();
7867        let source_path = temp.path().join("src/handlers.ts");
7868        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7869        std::fs::write(
7870            &source_path,
7871            "export const handlers = {\n  a: () => true,\n\n  b: () => false,\n};\n",
7872        )
7873        .unwrap();
7874
7875        let coverage_path = temp.path().join("coverage-final.json");
7876        write_single_file_istanbul_fixture(
7877            &coverage_path,
7878            &source_path,
7879            &serde_json::json!({
7880                "0": {
7881                    "name": "(anonymous_0)",
7882                    "line": 2,
7883                    "decl": {
7884                        "start": { "line": 2, "column": 5 },
7885                        "end": { "line": 2, "column": 6 }
7886                    },
7887                    "loc": {
7888                        "start": { "line": 2, "column": 11 },
7889                        "end": { "line": 2, "column": 15 }
7890                    }
7891                },
7892                "1": {
7893                    "name": "(anonymous_1)",
7894                    "line": 4,
7895                    "decl": {
7896                        "start": { "line": 4, "column": 5 },
7897                        "end": { "line": 4, "column": 6 }
7898                    },
7899                    "loc": {
7900                        "start": { "line": 4, "column": 11 },
7901                        "end": { "line": 4, "column": 16 }
7902                    }
7903                }
7904            }),
7905            &serde_json::json!({ "0": 1, "1": 0 }),
7906        );
7907
7908        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7909        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7910        let file_coverage = coverage.get(&canonical_source).unwrap();
7911
7912        assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
7913        assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
7914        assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
7915    }
7916
7917    /// istanbul-lib-instrument geometry for a function expression whose block
7918    /// body wraps an arrow, with the closing braces on a second line. A target
7919    /// on that line, equidistant from the outer `{` and the inner declaration,
7920    /// ties on distance and lies inside both bodies; the strictly innermost
7921    /// body wins.
7922    #[test]
7923    fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
7924        let temp = tempfile::TempDir::new().unwrap();
7925        let source_path = temp.path().join("src/nested.ts");
7926        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7927        std::fs::write(
7928            &source_path,
7929            "export const o = function () { const f = () => { return 1;\n                                       }; return f; };\n",
7930        )
7931        .unwrap();
7932
7933        let coverage_path = temp.path().join("coverage-final.json");
7934        write_single_file_istanbul_fixture(
7935            &coverage_path,
7936            &source_path,
7937            &serde_json::json!({
7938                "0": {
7939                    "name": "(anonymous_0)",
7940                    "line": 1,
7941                    "decl": {
7942                        "start": { "line": 1, "column": 17 },
7943                        "end": { "line": 1, "column": 18 }
7944                    },
7945                    "loc": {
7946                        "start": { "line": 1, "column": 29 },
7947                        "end": { "line": 2, "column": 53 }
7948                    }
7949                },
7950                "1": {
7951                    "name": "(anonymous_1)",
7952                    "line": 1,
7953                    "decl": {
7954                        "start": { "line": 1, "column": 41 },
7955                        "end": { "line": 1, "column": 42 }
7956                    },
7957                    "loc": {
7958                        "start": { "line": 1, "column": 47 },
7959                        "end": { "line": 2, "column": 40 }
7960                    }
7961                }
7962            }),
7963            &serde_json::json!({ "0": 1, "1": 0 }),
7964        );
7965
7966        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7967        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7968        let file_coverage = coverage.get(&canonical_source).unwrap();
7969
7970        assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
7971    }
7972
7973    /// Defensive: no producer emits partially overlapping bodies, but a tie
7974    /// between two containing spans that are not nested must not pick either.
7975    #[test]
7976    fn anonymous_tie_rejects_incomparable_containing_spans() {
7977        let file_coverage = IstanbulFileCoverage::new(
7978            vec![
7979                IstanbulFunctionCoverage {
7980                    name: "(anonymous_0)".to_string(),
7981                    coverage_pct: 100.0,
7982                    aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
7983                    decl_start: IstanbulPosition::new(10, 8),
7984                    header_holds_other_fn: false,
7985                    header_span: None,
7986                    body_span: Some(body_span((10, 14), (12, 30))),
7987                },
7988                IstanbulFunctionCoverage {
7989                    name: "(anonymous_1)".to_string(),
7990                    coverage_pct: 0.0,
7991                    aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
7992                    decl_start: IstanbulPosition::new(10, 20),
7993                    header_holds_other_fn: false,
7994                    header_span: None,
7995                    body_span: Some(body_span((11, 0), (14, 0))),
7996                },
7997            ],
7998            false,
7999        );
8000
8001        assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
8002    }
8003
8004    /// Two records whose primary aliases coincide (a multi-line parameter
8005    /// list whose body-start line and declaration column produce the same
8006    /// effective position as the inner arrow's declaration) stay ambiguous
8007    /// even though their bodies nest.
8008    #[test]
8009    fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
8010        let file_coverage = IstanbulFileCoverage::new(
8011            vec![
8012                IstanbulFunctionCoverage {
8013                    name: "(anonymous_0)".to_string(),
8014                    coverage_pct: 100.0,
8015                    aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
8016                    decl_start: IstanbulPosition::new(4, 11),
8017                    header_holds_other_fn: false,
8018                    header_span: None,
8019                    body_span: Some(body_span((4, 11), (4, 23))),
8020                },
8021                IstanbulFunctionCoverage {
8022                    name: "(anonymous_1)".to_string(),
8023                    coverage_pct: 0.0,
8024                    aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
8025                    decl_start: IstanbulPosition::new(4, 11),
8026                    header_holds_other_fn: false,
8027                    header_span: None,
8028                    body_span: Some(body_span((4, 18), (4, 23))),
8029                },
8030            ],
8031            false,
8032        );
8033
8034        assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
8035        assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
8036    }
8037
8038    #[test]
8039    fn colliding_anonymous_alias_uses_unique_safe_header_span() {
8040        let file_coverage = IstanbulFileCoverage::new(
8041            vec![
8042                IstanbulFunctionCoverage {
8043                    name: "(anonymous_0)".to_string(),
8044                    coverage_pct: 100.0,
8045                    aliases: vec![primary_alias(1, 0), secondary_alias(3, 4)],
8046                    decl_start: IstanbulPosition::new(1, 0),
8047                    header_holds_other_fn: false,
8048                    header_span: Some(body_span((1, 0), (5, 0))),
8049                    body_span: Some(body_span((5, 0), (8, 0))),
8050                },
8051                IstanbulFunctionCoverage {
8052                    name: "(anonymous_1)".to_string(),
8053                    coverage_pct: 0.0,
8054                    aliases: vec![primary_alias(10, 0), secondary_alias(3, 4)],
8055                    decl_start: IstanbulPosition::new(10, 0),
8056                    header_holds_other_fn: false,
8057                    header_span: None,
8058                    body_span: Some(body_span((10, 0), (12, 0))),
8059                },
8060            ],
8061            false,
8062        );
8063
8064        assert_eq!(file_coverage.lookup("<arrow>", 3, 4), Some(100.0));
8065    }
8066
8067    /// A secondary alias that collides with another record's secondary alias
8068    /// is dropped from both, and the shared position remains ambiguous.
8069    #[test]
8070    fn colliding_secondary_aliases_abstain_at_shared_position() {
8071        let file_coverage = IstanbulFileCoverage::new(
8072            vec![
8073                IstanbulFunctionCoverage {
8074                    name: "(anonymous_0)".to_string(),
8075                    coverage_pct: 100.0,
8076                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8077                    decl_start: IstanbulPosition::new(10, 0),
8078                    header_holds_other_fn: false,
8079                    header_span: None,
8080                    body_span: Some(body_span((12, 4), (20, 0))),
8081                },
8082                IstanbulFunctionCoverage {
8083                    name: "(anonymous_1)".to_string(),
8084                    coverage_pct: 0.0,
8085                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8086                    decl_start: IstanbulPosition::new(11, 0),
8087                    header_holds_other_fn: false,
8088                    header_span: None,
8089                    body_span: Some(body_span((12, 4), (18, 0))),
8090                },
8091            ],
8092            false,
8093        );
8094
8095        assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
8096        assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
8097        assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
8098    }
8099
8100    #[test]
8101    fn colliding_named_secondary_aliases_abstain_at_shared_position() {
8102        let file_coverage = IstanbulFileCoverage::new(
8103            vec![
8104                IstanbulFunctionCoverage {
8105                    name: "handler".to_string(),
8106                    coverage_pct: 100.0,
8107                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8108                    decl_start: IstanbulPosition::new(10, 0),
8109                    header_holds_other_fn: false,
8110                    header_span: None,
8111                    body_span: Some(body_span((12, 4), (20, 0))),
8112                },
8113                IstanbulFunctionCoverage {
8114                    name: "handler".to_string(),
8115                    coverage_pct: 0.0,
8116                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8117                    decl_start: IstanbulPosition::new(11, 0),
8118                    header_holds_other_fn: false,
8119                    header_span: None,
8120                    body_span: Some(body_span((12, 4), (18, 0))),
8121                },
8122            ],
8123            false,
8124        );
8125
8126        assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
8127        assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
8128        assert!(file_coverage.lookup("handler", 12, 4).is_none());
8129    }
8130
8131    #[test]
8132    fn invalid_body_location_does_not_create_an_alias() {
8133        let temp = tempfile::TempDir::new().unwrap();
8134        let source_path = temp.path().join("src/invalid-location.ts");
8135        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8136        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
8137
8138        let coverage_path = temp.path().join("coverage-final.json");
8139        write_single_file_istanbul_fixture(
8140            &coverage_path,
8141            &source_path,
8142            &serde_json::json!({
8143                "0": {
8144                    "name": "(anonymous_0)",
8145                    "line": 8,
8146                    "decl": {
8147                        "start": { "line": 8, "column": 14 },
8148                        "end": { "line": 8, "column": 25 }
8149                    },
8150                    "loc": {
8151                        "start": { "line": 22, "column": 1 },
8152                        "end": { "line": 20, "column": 6 }
8153                    }
8154                }
8155            }),
8156            &serde_json::json!({ "0": 1 }),
8157        );
8158
8159        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8160        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8161        let file_coverage = coverage.get(&canonical_source).unwrap();
8162
8163        assert!(file_coverage.lookup("handler", 22, 1).is_none());
8164    }
8165
8166    #[test]
8167    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
8168        let temp = tempfile::TempDir::new().unwrap();
8169        let source_path = temp.path().join("src/actor.ts");
8170        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8171        std::fs::write(
8172            &source_path,
8173            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
8174        )
8175        .unwrap();
8176
8177        let coverage_path = temp.path().join("coverage-final.json");
8178        write_single_file_istanbul_fixture(
8179            &coverage_path,
8180            &source_path,
8181            &serde_json::json!({
8182                "0": {
8183                    "name": "(anonymous_0)",
8184                    "line": 4,
8185                    "decl": {
8186                        "start": { "line": 1, "column": 28 },
8187                        "end": { "line": 4, "column": 26 }
8188                    },
8189                    "loc": {
8190                        "start": { "line": 4, "column": 27 },
8191                        "end": { "line": 6, "column": 1 }
8192                    }
8193                }
8194            }),
8195            &serde_json::json!({
8196                "0": 642
8197            }),
8198        );
8199
8200        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8201        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8202        let file_coverage = coverage.get(&canonical_source).unwrap();
8203
8204        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
8205    }
8206
8207    #[test]
8208    fn istanbul_lookup_exact_match() {
8209        let mut functions = rustc_hash::FxHashMap::default();
8210        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8211        let fc = test_istanbul_file_coverage(functions, false);
8212        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
8213    }
8214
8215    #[test]
8216    fn istanbul_lookup_fuzzy_match_within_offset() {
8217        let mut functions = rustc_hash::FxHashMap::default();
8218        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8219        let fc = test_istanbul_file_coverage(functions, false);
8220        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8221        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8222    }
8223
8224    #[test]
8225    fn istanbul_lookup_fuzzy_match_outside_offset() {
8226        let mut functions = rustc_hash::FxHashMap::default();
8227        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8228        let fc = test_istanbul_file_coverage(functions, false);
8229        assert!(fc.lookup("handleClick", 13, 0).is_none());
8230    }
8231
8232    #[test]
8233    fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
8234        let mut functions = rustc_hash::FxHashMap::default();
8235        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8236        let fc = test_istanbul_file_coverage(functions, true);
8237        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8238    }
8239
8240    #[test]
8241    fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
8242        let mut functions = rustc_hash::FxHashMap::default();
8243        functions.insert(("handleClick".to_string(), 29, 16), 72.0);
8244        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8245        let fc = test_istanbul_file_coverage(functions, true);
8246        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8247    }
8248
8249    #[test]
8250    fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
8251        let mut functions = rustc_hash::FxHashMap::default();
8252        functions.insert(("render".to_string(), 29, 0), 72.0);
8253        functions.insert(("render".to_string(), 80, 0), 10.0);
8254        let fc = test_istanbul_file_coverage(functions, true);
8255        assert!(fc.lookup("render", 10, 0).is_none());
8256    }
8257
8258    #[test]
8259    fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
8260        let mut functions = rustc_hash::FxHashMap::default();
8261        functions.insert(("render".to_string(), 11, 0), 72.0);
8262        functions.insert(("render".to_string(), 80, 0), 10.0);
8263        let fc = test_istanbul_file_coverage(functions, true);
8264        assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8265    }
8266
8267    #[test]
8268    fn istanbul_lookup_name_mismatch() {
8269        let mut functions = rustc_hash::FxHashMap::default();
8270        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8271        let fc = test_istanbul_file_coverage(functions, false);
8272        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
8273    }
8274
8275    #[test]
8276    fn istanbul_lookup_empty() {
8277        let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
8278        assert!(fc.lookup("anything", 1, 0).is_none());
8279    }
8280
8281    #[test]
8282    fn istanbul_lookup_fuzzy_picks_closest() {
8283        let mut functions = rustc_hash::FxHashMap::default();
8284        functions.insert(("render".to_string(), 8, 0), 60.0);
8285        functions.insert(("render".to_string(), 12, 0), 90.0);
8286        let fc = test_istanbul_file_coverage(functions, false);
8287        let result = fc.lookup("render", 10, 0);
8288        assert!(result.is_some());
8289        let pct = result.unwrap();
8290        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
8291    }
8292
8293    #[test]
8294    fn istanbul_lookup_anonymous_fallback_single_candidate() {
8295        let mut functions = rustc_hash::FxHashMap::default();
8296        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8297        let fc = test_istanbul_file_coverage(functions, false);
8298        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8299        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8300    }
8301
8302    #[test]
8303    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
8304        let mut functions = rustc_hash::FxHashMap::default();
8305        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
8306        let fc = test_istanbul_file_coverage(functions, false);
8307
8308        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
8309    }
8310
8311    #[test]
8312    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
8313        let mut functions = rustc_hash::FxHashMap::default();
8314        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8315        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8316        let fc = test_istanbul_file_coverage(functions, false);
8317        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8318    }
8319
8320    #[test]
8321    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
8322        let mut functions = rustc_hash::FxHashMap::default();
8323        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
8324        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
8325        let fc = test_istanbul_file_coverage(functions, false);
8326        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
8327        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
8328    }
8329
8330    #[test]
8331    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
8332        let mut functions = rustc_hash::FxHashMap::default();
8333        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
8334        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8335        let fc = test_istanbul_file_coverage(functions, false);
8336        assert!(fc.lookup("myHandler", 28, 0).is_none());
8337    }
8338
8339    #[test]
8340    fn istanbul_lookup_anonymous_fallback_outside_offset() {
8341        let mut functions = rustc_hash::FxHashMap::default();
8342        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8343        let fc = test_istanbul_file_coverage(functions, false);
8344        assert!(fc.lookup("myHandler", 31, 0).is_none());
8345    }
8346
8347    #[test]
8348    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
8349        let mut functions = rustc_hash::FxHashMap::default();
8350        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
8351        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
8352        let fc = test_istanbul_file_coverage(functions, false);
8353        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
8354    }
8355
8356    #[test]
8357    fn build_test_refs_empty() {
8358        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
8359        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
8360        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
8361        assert!(refs.is_empty());
8362    }
8363
8364    #[test]
8365    fn istanbul_crap_empty_complexity() {
8366        let result = istanbul_crap_default(&[], None, false);
8367        assert!((result.max_crap).abs() < f64::EPSILON);
8368        assert_eq!(result.signals.above, 0);
8369        assert_eq!(result.matched, 0);
8370        assert_eq!(result.total, 0);
8371    }
8372
8373    #[test]
8374    fn istanbul_crap_match_statistics() {
8375        let funcs = vec![make_fn_complexity(5), {
8376            let mut f = make_fn_complexity(3);
8377            f.name = "other_fn".into();
8378            f.line = 10;
8379            f
8380        }];
8381        let mut functions = rustc_hash::FxHashMap::default();
8382        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
8383        let file_cov = test_istanbul_file_coverage(functions, false);
8384        let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
8385        assert_eq!(result.matched, 1);
8386        assert_eq!(result.total, 2);
8387    }
8388
8389    #[test]
8390    fn estimated_crap_multiple_functions_mixed_coverage() {
8391        let funcs = vec![
8392            make_fn_complexity(10), // name "test_fn" line 1
8393            {
8394                let mut f = make_fn_complexity(3);
8395                f.name = "helper".into();
8396                f.line = 20;
8397                f
8398            },
8399        ];
8400        let mut refs = rustc_hash::FxHashSet::default();
8401        refs.insert("test_fn".to_string());
8402        let result = estimated_crap_default(
8403            &funcs,
8404            &refs,
8405            true,
8406            fallow_output::CoverageSource::Estimated,
8407        );
8408        let (max, above) = (result.max_crap, result.signals.above);
8409        assert!(max > 10.0);
8410        assert_eq!(above, 0);
8411    }
8412}