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