Skip to main content

fallow_engine/health/
scoring.rs

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