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 unplaceable coordinates 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`. Positions are unsigned,
1834/// so one such coordinate rejects the entire map, and it always lands in
1835/// `branchMap`, which nothing here reads. The retry costs a second parse and
1836/// only runs after the strict one has already failed.
1837fn parse_coverage_map_tolerantly(
1838    json: &str,
1839) -> Result<std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>, String> {
1840    match oxc_coverage_instrument::parse_coverage_map(json) {
1841        Ok(raw) => Ok(raw),
1842        Err(strict_error) => {
1843            let mut value: serde_json::Value =
1844                serde_json::from_str(json).map_err(|_| strict_error.to_string())?;
1845            if !clamp_negative_positions(&mut value) {
1846                return Err(strict_error.to_string());
1847            }
1848            serde_json::from_value(value).map_err(|_| strict_error.to_string())
1849        }
1850    }
1851}
1852
1853/// Clamp every negative `line` or `column` in the tree to zero, reporting
1854/// whether anything changed. Zero is what the shared position type already
1855/// uses for a coordinate a producer left null or absent.
1856fn clamp_negative_positions(value: &mut serde_json::Value) -> bool {
1857    match value {
1858        serde_json::Value::Object(entries) => {
1859            let mut clamped = false;
1860            for (key, child) in entries.iter_mut() {
1861                if matches!(key.as_str(), "line" | "column")
1862                    && child.as_i64().is_some_and(|number| number < 0)
1863                {
1864                    *child = serde_json::Value::from(0);
1865                    clamped = true;
1866                    continue;
1867                }
1868                clamped |= clamp_negative_positions(child);
1869            }
1870            clamped
1871        }
1872        serde_json::Value::Array(items) => items.iter_mut().fold(false, |clamped, item| {
1873            clamped | clamp_negative_positions(item)
1874        }),
1875        _ => false,
1876    }
1877}
1878
1879/// The property name behind an accessor record, when a producer prefixed it.
1880///
1881/// istanbul-lib-instrument leaves a class accessor anonymous, but raw V8
1882/// coverage and `oxc-coverage-instrument` record `get area` and `set area`,
1883/// while fallow extracts the unit as `area`. The prefix is the whole
1884/// difference, so a record keeps its own spelling and answers to the bare
1885/// property name as well.
1886fn accessor_property_name(name: &str) -> Option<&str> {
1887    let property = name
1888        .strip_prefix("get ")
1889        .or_else(|| name.strip_prefix("set "))?;
1890    (!property.is_empty() && !property.contains(' ')).then_some(property)
1891}
1892
1893/// Rebase one Istanbul file path from `coverage_root` onto `project_root`.
1894///
1895/// When the recorded path does not start with `coverage_root` verbatim, retry
1896/// with its canonicalized form: coverage generated inside a symlinked
1897/// directory (macOS `/var` vs `/private/var`) records the symlinked spelling
1898/// while the rebase prefix is typically canonical. Paths under neither
1899/// spelling are kept as-is, matching the previous behavior.
1900fn rebase_coverage_path(
1901    raw_path: std::path::PathBuf,
1902    coverage_root: &std::path::Path,
1903    project_root: &std::path::Path,
1904) -> std::path::PathBuf {
1905    if let Ok(rel) = raw_path.strip_prefix(coverage_root) {
1906        return project_root.join(rel);
1907    }
1908    if let Ok(canonical) = dunce::canonicalize(&raw_path)
1909        && let Ok(rel) = canonical.strip_prefix(coverage_root)
1910    {
1911        return project_root.join(rel);
1912    }
1913    raw_path
1914}
1915
1916fn istanbul_function_coverage(
1917    fn_entry: &oxc_coverage_instrument::FnEntry,
1918    coverage_pct: f64,
1919    source_index: Option<&IstanbulSourceIndex<'_>>,
1920) -> Option<IstanbulFunctionCoverage> {
1921    let body_span = IstanbulSpan::from_entry(fn_entry, source_index);
1922    let header_span = IstanbulSpan::header_from_entry(fn_entry, source_index);
1923    let decl_start = normalized_istanbul_position(
1924        fn_entry.decl.start.line,
1925        fn_entry.decl.start.column,
1926        source_index,
1927    )?;
1928    let effective_position = normalized_istanbul_position(
1929        effective_istanbul_fn_line(fn_entry),
1930        fn_entry.decl.start.column,
1931        source_index,
1932    );
1933    let candidates = [
1934        effective_position.map(|position| IstanbulAlias {
1935            position,
1936            primary: true,
1937        }),
1938        Some(IstanbulAlias {
1939            position: decl_start,
1940            primary: true,
1941        }),
1942        body_span.map(|span| IstanbulAlias {
1943            position: span.start,
1944            primary: false,
1945        }),
1946        named_function_syntax_alias(fn_entry, source_index),
1947    ];
1948    let mut aliases: Vec<IstanbulAlias> = Vec::with_capacity(candidates.len());
1949    for candidate in candidates.into_iter().flatten() {
1950        if !aliases
1951            .iter()
1952            .any(|alias| alias.position == candidate.position)
1953        {
1954            aliases.push(candidate);
1955        }
1956    }
1957
1958    Some(IstanbulFunctionCoverage {
1959        name: fn_entry.name.clone(),
1960        coverage_pct,
1961        aliases,
1962        decl_start,
1963        header_holds_other_fn: false,
1964        header_span,
1965        body_span,
1966    })
1967}
1968
1969/// Source index used to reconcile Istanbul's UTF-16 columns with Fallow's
1970/// UTF-8 byte columns and recover exact named-function syntax starts.
1971struct IstanbulSourceIndex<'a> {
1972    source: &'a str,
1973    line_starts: Vec<usize>,
1974    non_ascii_lines: rustc_hash::FxHashMap<usize, Utf16LineIndex>,
1975    named_function_starts: rustc_hash::FxHashMap<usize, usize>,
1976}
1977
1978struct Utf16LineIndex {
1979    utf16_len: u32,
1980    byte_len: usize,
1981    checkpoints: Vec<Utf16Checkpoint>,
1982}
1983
1984struct Utf16Checkpoint {
1985    utf16_start: u32,
1986    utf16_end: u32,
1987    byte_end: usize,
1988}
1989
1990impl<'a> IstanbulSourceIndex<'a> {
1991    fn new(source: &'a str, path: &std::path::Path) -> Self {
1992        let mut line_starts = vec![0];
1993        line_starts.extend(
1994            source
1995                .bytes()
1996                .enumerate()
1997                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
1998        );
1999        let non_ascii_lines = utf16_line_indexes(source, &line_starts);
2000
2001        let named_function_starts = named_function_starts_from_clean_parse(source, path);
2002
2003        Self {
2004            source,
2005            line_starts,
2006            non_ascii_lines,
2007            named_function_starts,
2008        }
2009    }
2010
2011    fn byte_position(&self, line: u32, utf16_column: u32) -> Option<IstanbulPosition> {
2012        let line_index = usize::try_from(line.checked_sub(1)?).ok()?;
2013        let line_start = *self.line_starts.get(line_index)?;
2014        let line_end = self
2015            .line_starts
2016            .get(line_index + 1)
2017            .copied()
2018            .map_or(self.source.len(), |next_start| next_start - 1);
2019        let line_source = self.source.get(line_start..line_end)?;
2020        let byte_column = if let Some(index) = self.non_ascii_lines.get(&line_index) {
2021            index.byte_column(utf16_column)?
2022        } else {
2023            let byte_column = usize::try_from(utf16_column).ok()?;
2024            (byte_column <= line_source.len()).then_some(byte_column)?
2025        };
2026        Some(IstanbulPosition::new(
2027            line,
2028            u32::try_from(byte_column).ok()?,
2029        ))
2030    }
2031
2032    fn absolute_offset(&self, line: u32, utf16_column: u32) -> Option<usize> {
2033        let position = self.byte_position(line, utf16_column)?;
2034        let line_index = usize::try_from(position.line.checked_sub(1)?).ok()?;
2035        self.line_starts
2036            .get(line_index)?
2037            .checked_add(position.col as usize)
2038    }
2039
2040    fn position_at_offset(&self, offset: usize) -> Option<IstanbulPosition> {
2041        if offset > self.source.len() {
2042            return None;
2043        }
2044        let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
2045        Some(IstanbulPosition::new(
2046            u32::try_from(line_index + 1).ok()?,
2047            u32::try_from(offset.checked_sub(self.line_starts[line_index])?).ok()?,
2048        ))
2049    }
2050
2051    fn named_function_start(
2052        &self,
2053        fn_entry: &oxc_coverage_instrument::FnEntry,
2054    ) -> Option<IstanbulPosition> {
2055        let declaration_offset =
2056            self.absolute_offset(fn_entry.decl.start.line, fn_entry.decl.start.column)?;
2057        let syntax_offset = *self.named_function_starts.get(&declaration_offset)?;
2058        self.position_at_offset(syntax_offset)
2059    }
2060}
2061
2062impl Utf16LineIndex {
2063    fn byte_column(&self, utf16_column: u32) -> Option<usize> {
2064        if utf16_column > self.utf16_len {
2065            return None;
2066        }
2067        let completed = self
2068            .checkpoints
2069            .partition_point(|checkpoint| checkpoint.utf16_end <= utf16_column);
2070        if let Some(next) = self.checkpoints.get(completed)
2071            && utf16_column > next.utf16_start
2072        {
2073            return None;
2074        }
2075        let (utf16_base, byte_base) = completed
2076            .checked_sub(1)
2077            .and_then(|index| self.checkpoints.get(index))
2078            .map_or((0, 0), |checkpoint| {
2079                (checkpoint.utf16_end, checkpoint.byte_end)
2080            });
2081        let ascii_width = usize::try_from(utf16_column.checked_sub(utf16_base)?).ok()?;
2082        let byte_column = byte_base.checked_add(ascii_width)?;
2083        (byte_column <= self.byte_len).then_some(byte_column)
2084    }
2085}
2086
2087fn utf16_line_indexes(
2088    source: &str,
2089    line_starts: &[usize],
2090) -> rustc_hash::FxHashMap<usize, Utf16LineIndex> {
2091    let mut indexes = rustc_hash::FxHashMap::default();
2092    for (line_index, &line_start) in line_starts.iter().enumerate() {
2093        let line_end = line_starts
2094            .get(line_index + 1)
2095            .copied()
2096            .map_or(source.len(), |next_start| next_start - 1);
2097        let Some(line) = source.get(line_start..line_end) else {
2098            continue;
2099        };
2100        if line.is_ascii() {
2101            continue;
2102        }
2103        let mut utf16_column = 0_u32;
2104        let mut checkpoints = Vec::new();
2105        for (byte_column, character) in line.char_indices() {
2106            let utf16_width = character.len_utf16() as u32;
2107            if !character.is_ascii() {
2108                checkpoints.push(Utf16Checkpoint {
2109                    utf16_start: utf16_column,
2110                    utf16_end: utf16_column.saturating_add(utf16_width),
2111                    byte_end: byte_column.saturating_add(character.len_utf8()),
2112                });
2113            }
2114            utf16_column = utf16_column.saturating_add(utf16_width);
2115        }
2116        indexes.insert(
2117            line_index,
2118            Utf16LineIndex {
2119                utf16_len: utf16_column,
2120                byte_len: line.len(),
2121                checkpoints,
2122            },
2123        );
2124    }
2125    indexes
2126}
2127
2128fn named_function_starts_from_clean_parse(
2129    source: &str,
2130    path: &std::path::Path,
2131) -> rustc_hash::FxHashMap<usize, usize> {
2132    let source_type = match path.extension().and_then(|extension| extension.to_str()) {
2133        Some("gts") => oxc_span::SourceType::ts(),
2134        Some("gjs") => oxc_span::SourceType::mjs(),
2135        _ => oxc_span::SourceType::from_path(path).unwrap_or_default(),
2136    };
2137    if let Some(starts) = collect_named_function_starts(source, source_type) {
2138        return starts;
2139    }
2140    if source_type.is_jsx() {
2141        return rustc_hash::FxHashMap::default();
2142    }
2143    let jsx_source_type = if source_type.is_typescript() {
2144        oxc_span::SourceType::tsx()
2145    } else {
2146        oxc_span::SourceType::jsx()
2147    };
2148    collect_named_function_starts(source, jsx_source_type).unwrap_or_default()
2149}
2150
2151fn collect_named_function_starts(
2152    source: &str,
2153    source_type: oxc_span::SourceType,
2154) -> Option<rustc_hash::FxHashMap<usize, usize>> {
2155    let allocator = oxc_allocator::Allocator::default();
2156    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
2157    if parsed.fatal_error || !parsed.diagnostics.is_empty() {
2158        return None;
2159    }
2160    let mut starts = rustc_hash::FxHashMap::default();
2161    let mut collector = NamedFunctionSyntaxCollector {
2162        starts: &mut starts,
2163    };
2164    oxc_ast_visit::Visit::visit_program(&mut collector, &parsed.program);
2165    Some(starts)
2166}
2167
2168struct NamedFunctionSyntaxCollector<'a> {
2169    starts: &'a mut rustc_hash::FxHashMap<usize, usize>,
2170}
2171
2172impl<'ast> oxc_ast_visit::Visit<'ast> for NamedFunctionSyntaxCollector<'_> {
2173    fn visit_function(
2174        &mut self,
2175        function: &oxc_ast::ast::Function<'ast>,
2176        flags: oxc_syntax::scope::ScopeFlags,
2177    ) {
2178        if let Some(identifier) = &function.id
2179            && let (Ok(identifier_start), Ok(syntax_start)) = (
2180                usize::try_from(identifier.span.start),
2181                usize::try_from(function.span.start),
2182            )
2183        {
2184            self.starts.insert(identifier_start, syntax_start);
2185        }
2186        oxc_ast_visit::walk::walk_function(self, function, flags);
2187    }
2188}
2189
2190fn normalized_istanbul_position(
2191    line: u32,
2192    column: u32,
2193    source_index: Option<&IstanbulSourceIndex<'_>>,
2194) -> Option<IstanbulPosition> {
2195    match source_index {
2196        Some(index) => index.byte_position(line, column),
2197        None => Some(IstanbulPosition::new(line, column)),
2198    }
2199}
2200
2201/// Exact syntax-backed alias for a named function's source start.
2202///
2203/// Istanbul declares a named function at its identifier, while Fallow records
2204/// the containing `function` or `async` keyword. Parser tokens distinguish
2205/// that real syntax across legal trivia from unrelated same-width text.
2206fn named_function_syntax_alias(
2207    fn_entry: &oxc_coverage_instrument::FnEntry,
2208    source_index: Option<&IstanbulSourceIndex<'_>>,
2209) -> Option<IstanbulAlias> {
2210    if is_anonymous_istanbul_name(&fn_entry.name) {
2211        return None;
2212    }
2213    Some(IstanbulAlias {
2214        position: source_index?.named_function_start(fn_entry)?,
2215        primary: true,
2216    })
2217}
2218
2219fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
2220    if fn_entry.line > 0 {
2221        fn_entry.line
2222    } else {
2223        fn_entry.decl.start.line
2224    }
2225}
2226
2227/// Statements owned by a single `fnMap` record.
2228#[derive(Default)]
2229struct StatementTally {
2230    covered: u32,
2231    total: u32,
2232}
2233
2234/// Whether `outer` encloses `inner`, with both bounds inclusive.
2235///
2236/// These are the bounds the per-function statement scan has always used, so
2237/// which statements a function can claim is unchanged; only how a contested
2238/// statement is awarded moved.
2239fn istanbul_range_contains(
2240    outer: &oxc_coverage_instrument::Location,
2241    inner: &oxc_coverage_instrument::Location,
2242) -> bool {
2243    let after_start = inner.start.line > outer.start.line
2244        || (inner.start.line == outer.start.line && inner.start.column >= outer.start.column);
2245    let before_end = inner.end.line < outer.end.line
2246        || (inner.end.line == outer.end.line && inner.end.column <= outer.end.column);
2247    after_start && before_end
2248}
2249
2250/// Assign every statement to the innermost `fnMap` record that encloses it.
2251///
2252/// A nested function body lies inside the enclosing function's `loc`, so a
2253/// bare containment test charges a closure's statements to the function that
2254/// returns it as well, and an outer function that ran every statement of its
2255/// own is scored down for code it does not own. Ownership here is exclusive:
2256/// a statement counts for exactly one record, or for none at all when no
2257/// record encloses it, which keeps module-scope statements out of every
2258/// function as before.
2259///
2260/// Innermost is the record with the greatest `loc` start. A tie breaks on the
2261/// smallest `loc` end, and a remaining tie between two byte-identical ranges
2262/// keeps the first record in `fnMap` key order, which is stable because the
2263/// map is a `BTreeMap`. Identical ranges are pathological and any winner is
2264/// arbitrary, so determinism is the property that matters.
2265///
2266/// Computed once per file for the same reason as
2267/// [`mark_headers_holding_other_fns`]: every record needs the answer, and the
2268/// alternative rescans the whole statement map once per record.
2269fn tally_statements_by_owner(
2270    file_cov: &oxc_coverage_instrument::FileCoverage,
2271) -> rustc_hash::FxHashMap<&str, StatementTally> {
2272    let mut owners: rustc_hash::FxHashMap<&str, StatementTally> = rustc_hash::FxHashMap::default();
2273
2274    for (stmt_id, stmt_loc) in &file_cov.statement_map {
2275        let mut owner: Option<(&str, &oxc_coverage_instrument::Location)> = None;
2276
2277        for (fn_id, fn_entry) in &file_cov.fn_map {
2278            if !istanbul_range_contains(&fn_entry.loc, stmt_loc) {
2279                continue;
2280            }
2281            let inner_than_current = owner.is_none_or(|(_, best)| {
2282                let candidate = (
2283                    (fn_entry.loc.start.line, fn_entry.loc.start.column),
2284                    std::cmp::Reverse((fn_entry.loc.end.line, fn_entry.loc.end.column)),
2285                );
2286                let incumbent = (
2287                    (best.start.line, best.start.column),
2288                    std::cmp::Reverse((best.end.line, best.end.column)),
2289                );
2290                candidate > incumbent
2291            });
2292            if inner_than_current {
2293                owner = Some((fn_id.as_str(), &fn_entry.loc));
2294            }
2295        }
2296
2297        let Some((fn_id, _)) = owner else {
2298            continue;
2299        };
2300        let tally = owners.entry(fn_id).or_default();
2301        tally.total += 1;
2302        if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
2303            tally.covered += 1;
2304        }
2305    }
2306
2307    owners
2308}
2309
2310/// Compute statement-level coverage percentage for a single function.
2311///
2312/// Takes the fraction of the statements the function owns that have non-zero
2313/// hit counts, where ownership comes from [`tally_statements_by_owner`] and a
2314/// statement belongs only to the innermost function that encloses it. When the
2315/// function owns no statements (a one-liner arrow, a getter, an outer function
2316/// whose whole body is a nested declaration, or a producer that emits an empty
2317/// `statementMap`), falls back to the function hit count as a binary signal.
2318fn compute_function_statement_coverage(
2319    file_cov: &oxc_coverage_instrument::FileCoverage,
2320    fn_id: &str,
2321    owned: Option<&StatementTally>,
2322) -> f64 {
2323    match owned {
2324        Some(tally) if tally.total > 0 => f64::from(tally.covered) / f64::from(tally.total) * 100.0,
2325        _ => {
2326            let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
2327            if hit > 0 { 100.0 } else { 0.0 }
2328        }
2329    }
2330}
2331
2332/// Count unused VALUE exports per file path for O(1) lookup.
2333///
2334/// Type-only exports (interfaces, type aliases) are intentionally excluded ---
2335/// they are a different concern than unused functions/components.
2336fn count_unused_exports_by_path(
2337    unused_exports: &[crate::results::UnusedExportFinding],
2338) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
2339    let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
2340    for exp in unused_exports {
2341        *map.entry(exp.export.path.as_path()).or_default() += 1;
2342    }
2343    map
2344}
2345
2346/// Compute the maintainability index for a single file.
2347///
2348/// Formula:
2349/// ```text
2350/// dampening = min(lines / 50, 1.0)
2351/// fan_out_penalty = min(ln(fan_out + 1) * 4, 15)
2352/// MI = 100 - (complexity_density * 30 * dampening) - (dead_code_ratio * 20) - fan_out_penalty
2353/// ```
2354///
2355/// The dampening factor prevents complexity density from dominating the score
2356/// on small files. A 5-line utility with CC=2 has density 0.40, but is trivially
2357/// readable; without dampening it scores worse than a 192-line function with CC=57
2358/// (density 0.30). Files under 50 lines get proportionally reduced density weight.
2359///
2360/// Fan-out uses logarithmic scaling capped at 15 points to reflect diminishing
2361/// marginal risk (the 30th import is less concerning than the 5th) and prevent
2362/// composition-root files from being unfairly penalized.
2363///
2364/// Clamped to \[0, 100\]. Higher is better.
2365fn compute_maintainability_index(
2366    complexity_density: f64,
2367    dead_code_ratio: f64,
2368    fan_out: usize,
2369    lines: u32,
2370) -> f64 {
2371    let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
2372    let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
2373    #[expect(
2374        clippy::suboptimal_flops,
2375        reason = "formula matches documented specification"
2376    )]
2377    let score = 100.0
2378        - (complexity_density * 30.0 * dampening)
2379        - (dead_code_ratio * 20.0)
2380        - fan_out_penalty;
2381    score.clamp(0.0, 100.0)
2382}
2383
2384fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
2385    (100.0 - score.maintainability_index).clamp(0.0, 100.0)
2386}
2387
2388/// True when the file's CRAP signal is fully covered by configuration: nothing
2389/// meets its effective ceiling while something would have been flagged at the
2390/// canonical 30.0 baseline, or CRAP enforcement is disabled entirely
2391/// (`max_crap_threshold <= 0`). Mirrors the findings pipeline, which emits no
2392/// CRAP finding in exactly these states, so the row must not read `risk`.
2393#[must_use]
2394pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
2395    let signal = file_score_crap_signal(score, max_crap_threshold);
2396    max_crap_threshold <= 0.0
2397        || (score.crap_above_threshold == 0
2398            && if signal.suppression_aware {
2399                signal.maximum >= CRAP_THRESHOLD
2400            } else {
2401                score.crap_exempted > 0
2402            })
2403}
2404
2405/// The complete CRAP metric/ceiling pair used for triage and remediation.
2406pub struct FileScoreCrapSignal {
2407    /// Maximum remaining score, or the raw maximum for legacy rows.
2408    pub maximum: f64,
2409    /// Effective ceiling belonging to the same population as the maximum.
2410    pub threshold: f64,
2411    suppression_aware: bool,
2412}
2413
2414impl FileScoreCrapSignal {
2415    /// JSON metric whose value the signal carries.
2416    pub const fn metric(&self) -> &'static str {
2417        if self.suppression_aware {
2418            "crap_unsuppressed_max"
2419        } else {
2420            "crap_max"
2421        }
2422    }
2423}
2424
2425/// Select the unsuppressed pair only when complete. Absent or partial external
2426/// metadata uses the complete legacy pair, never a mixture of populations.
2427pub fn file_score_crap_signal(score: &FileHealthScore, global: f64) -> FileScoreCrapSignal {
2428    match (
2429        score.crap_unsuppressed_max,
2430        score.crap_unsuppressed_effective_threshold,
2431    ) {
2432        (Some(maximum), Some(threshold)) => FileScoreCrapSignal {
2433            maximum,
2434            threshold,
2435            suppression_aware: true,
2436        },
2437        _ => FileScoreCrapSignal {
2438            maximum: score.crap_max,
2439            threshold: score.crap_effective_threshold.unwrap_or(global),
2440            suppression_aware: false,
2441        },
2442    }
2443}
2444
2445/// CRAP concern bands generalized over the selected metric pair's ceiling `t`.
2446/// The complete unsuppressed pair takes precedence over legacy metrics. At `t = 30`
2447/// the breakpoints are the historical (15, 30, 100). A fully exempt file
2448/// scores `0.0`: band generalization alone cannot drop an exempted file below
2449/// its structural concern, so the tag would keep reading `risk` against a run
2450/// with zero findings.
2451fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2452    if file_score_fully_crap_exempt(score, max_crap_threshold) {
2453        return 0.0;
2454    }
2455    let signal = file_score_crap_signal(score, max_crap_threshold);
2456    let crap_max = signal.maximum;
2457    let t = signal.threshold;
2458    let half = t / 2.0;
2459    let saturation = t * 10.0 / 3.0;
2460    if crap_max <= 0.0 {
2461        0.0
2462    } else if crap_max < half {
2463        (crap_max / half) * 45.0
2464    } else if crap_max < t {
2465        ((crap_max - half) / half).mul_add(30.0, 45.0)
2466    } else if crap_max < saturation {
2467        ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
2468    } else {
2469        100.0
2470    }
2471}
2472
2473fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2474    file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
2475}
2476
2477/// Which signal places a file at its triage rank: its structural quality (low
2478/// maintainability index) or its untested complexity (CRAP risk). Surfaced per
2479/// row so the human file-scores table can label why a file sits where it does
2480/// when the two axes disagree (e.g. a low-CRAP file outranking a higher-CRAP
2481/// one because its MI is the worse signal).
2482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2483pub enum FileScoreConcern {
2484    /// Ranked by structural quality: a low maintainability index.
2485    Structural,
2486    /// Ranked by untested complexity: a high CRAP score.
2487    Risk,
2488}
2489
2490impl FileScoreConcern {
2491    /// Short lowercase label for the human file-scores table.
2492    pub const fn label(self) -> &'static str {
2493        match self {
2494            Self::Structural => "structure",
2495            Self::Risk => "risk",
2496        }
2497    }
2498}
2499
2500/// Classify which concern drove `score` to its rank. A file with no CRAP
2501/// concern (no CRAP risk at all, or every breaching function exempted by its
2502/// effective ceiling) is always `Structural`; otherwise the larger concern
2503/// wins, with ties (and the boundary where the two are equal) resolving to
2504/// `Risk` because untested complexity is the more urgent signal to act on.
2505///
2506/// `max_crap_threshold` is the run global (`summary.max_crap_threshold`), the
2507/// fallback ceiling for rows without a `crap_effective_threshold` of their own.
2508pub fn file_score_concern_axis(
2509    score: &FileHealthScore,
2510    max_crap_threshold: f64,
2511) -> FileScoreConcern {
2512    let crap_concern = file_score_crap_concern(score, max_crap_threshold);
2513    if crap_concern <= 0.0 {
2514        FileScoreConcern::Structural
2515    } else if crap_concern >= file_score_structural_concern(score) {
2516        FileScoreConcern::Risk
2517    } else {
2518        FileScoreConcern::Structural
2519    }
2520}
2521
2522fn compare_file_score_triage(
2523    a: &FileHealthScore,
2524    b: &FileHealthScore,
2525    max_crap_threshold: f64,
2526) -> std::cmp::Ordering {
2527    file_score_triage_concern(b, max_crap_threshold)
2528        .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
2529        .then_with(|| {
2530            file_score_crap_signal(b, max_crap_threshold)
2531                .maximum
2532                .total_cmp(&file_score_crap_signal(a, max_crap_threshold).maximum)
2533        })
2534        .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
2535        .then_with(|| a.path.cmp(&b.path))
2536}
2537
2538/// Inputs for [`compute_file_scores`], bundled so the analysis artifacts stay
2539/// a separate owned argument.
2540#[derive(Clone, Copy)]
2541pub(super) struct FileScoreComputeInput<'a> {
2542    pub(super) modules: &'a [crate::source::ModuleInfo],
2543    pub(super) file_paths:
2544        &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2545    pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2546    pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
2547    pub(super) root: &'a std::path::Path,
2548    pub(super) crap_thresholds: CrapScoreThresholds<'a>,
2549    /// Build the static edge index for change coupling. Only a run that reads
2550    /// the commit history needs it, so other runs skip the graph walk.
2551    pub(super) with_static_links: bool,
2552}
2553
2554/// Compute per-file health scores using a pre-computed analysis output.
2555///
2556/// The caller provides an `AnalysisOutput` (with graph and dead code results)
2557/// so this function does not need to re-run the analysis pipeline. Complexity
2558/// density is derived from the already-parsed modules.
2559pub(super) fn compute_file_scores(
2560    input: FileScoreComputeInput<'_>,
2561    analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2562) -> Result<FileScoreOutput, String> {
2563    let FileScoreComputeInput {
2564        modules,
2565        file_paths,
2566        changed_files,
2567        istanbul_coverage,
2568        root,
2569        crap_thresholds,
2570        with_static_links,
2571    } = input;
2572    let retained_graph = analysis_output.graph.ok_or("graph not available")?;
2573    let test_coverage = retained_graph.static_test_coverage();
2574    let graph = retained_graph.as_graph();
2575    let results = &analysis_output.results;
2576
2577    let circular_files = collect_circular_files(results);
2578    let top_complex_fns = collect_top_complex_fns(modules, file_paths);
2579    let cycle_members = collect_cycle_members(results);
2580    let direct_callers = collect_direct_callers(graph, file_paths);
2581    let unused_export_names = collect_unused_export_names(results);
2582
2583    let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
2584        .unused_files
2585        .iter()
2586        .map(|f| f.file.path.as_path())
2587        .collect();
2588
2589    let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
2590
2591    let FileScoreCoverageSetup {
2592        module_by_id,
2593        coverage,
2594    } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
2595
2596    let template_inherit =
2597        build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
2598
2599    let mut acc = accumulate_file_scores(
2600        unused_export_names,
2601        &FileScoreLoopCtx {
2602            graph,
2603            test_coverage,
2604            file_paths,
2605            module_by_id: &module_by_id,
2606            unused_files: &unused_files,
2607            unused_exports_by_path: &unused_exports_by_path,
2608            template_inherit: &template_inherit,
2609            istanbul_coverage,
2610            root,
2611            crap_thresholds,
2612        },
2613    );
2614    acc.scores = finalize_file_score_list(
2615        acc.scores,
2616        changed_files,
2617        crap_thresholds.resolver.global.crap,
2618    );
2619
2620    Ok(build_file_score_output(FileScoreOutputParts {
2621        graph,
2622        with_static_links,
2623        file_paths,
2624        results,
2625        scores: acc.scores,
2626        coverage,
2627        circular_files,
2628        top_complex_fns,
2629        crap_target_fns: acc.crap_target_fns,
2630        entry_points: acc.entry_points,
2631        value_export_counts: acc.value_export_counts,
2632        unused_export_names: acc.unused_export_names,
2633        cycle_members,
2634        direct_callers,
2635        istanbul_matched: acc.istanbul_matched,
2636        istanbul_total: acc.istanbul_total,
2637        istanbul_files_joined: acc.istanbul_files_joined,
2638        istanbul_files_total: acc.istanbul_files_total,
2639        coverage_input_format: istanbul_coverage.map(IstanbulCoverage::format),
2640        per_function_crap: acc.per_function_crap,
2641        template_inherit,
2642    }))
2643}
2644
2645/// Read-only inputs threaded into the per-node file-score loop.
2646struct FileScoreLoopCtx<'a> {
2647    graph: &'a fallow_graph::graph::ModuleGraph,
2648    test_coverage: StaticTestCoverage<'a>,
2649    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2650    module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2651    unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
2652    unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
2653    template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2654    istanbul_coverage: Option<&'a IstanbulCoverage>,
2655    /// Project root used to relativize paths into the override resolver's glob
2656    /// space, matching the findings pipeline's `strip_prefix` on the same root.
2657    root: &'a std::path::Path,
2658    crap_thresholds: CrapScoreThresholds<'a>,
2659}
2660
2661/// Mutable accumulators populated by the per-node file-score loop.
2662struct FileScoreAccumulator {
2663    scores: Vec<FileHealthScore>,
2664    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
2665    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
2666    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2667    crap_target_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
2668    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2669    istanbul_matched: usize,
2670    istanbul_files_joined: usize,
2671    istanbul_files_total: usize,
2672    istanbul_total: usize,
2673}
2674
2675impl FileScoreAccumulator {
2676    /// Empty accumulator with the score vector pre-sized to the module count.
2677    fn with_capacity(modules: usize) -> Self {
2678        FileScoreAccumulator {
2679            scores: Vec::with_capacity(modules),
2680            entry_points: rustc_hash::FxHashSet::default(),
2681            value_export_counts: rustc_hash::FxHashMap::default(),
2682            unused_export_names: rustc_hash::FxHashMap::default(),
2683            crap_target_fns: rustc_hash::FxHashMap::default(),
2684            per_function_crap: rustc_hash::FxHashMap::default(),
2685            istanbul_matched: 0,
2686            istanbul_total: 0,
2687            istanbul_files_joined: 0,
2688            istanbul_files_total: 0,
2689        }
2690    }
2691}
2692
2693/// Drive the per-node loop, returning an accumulator with one score per
2694/// analyzable file. `unused_export_names` seeds the accumulator's same field.
2695fn accumulate_file_scores(
2696    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2697    ctx: &FileScoreLoopCtx<'_>,
2698) -> FileScoreAccumulator {
2699    let mut acc = FileScoreAccumulator {
2700        unused_export_names,
2701        istanbul_files_total: ctx
2702            .istanbul_coverage
2703            .map_or(0, IstanbulCoverage::file_count),
2704        ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
2705    };
2706    for node in &ctx.graph.modules {
2707        let Some(path) = ctx.file_paths.get(&node.file_id) else {
2708            continue;
2709        };
2710        record_entry_point(&mut acc.entry_points, node, path);
2711        let score = compute_one_file_score(&mut acc, ctx, node, path);
2712        acc.scores.push(score);
2713    }
2714    acc
2715}
2716
2717/// Apply the changed-file scope filter, drop zero-function barrels, and sort by
2718/// risk-aware triage concern.
2719fn finalize_file_score_list(
2720    mut scores: Vec<FileHealthScore>,
2721    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2722    max_crap_threshold: f64,
2723) -> Vec<FileHealthScore> {
2724    if let Some(changed) = changed_files {
2725        scores.retain(|s| changed.contains(&s.path));
2726    }
2727    scores.retain(|s| s.function_count > 0);
2728    scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
2729    scores
2730}
2731
2732/// Compute the `FileHealthScore` for one node and fold its side data into `acc`.
2733fn compute_one_file_score(
2734    acc: &mut FileScoreAccumulator,
2735    ctx: &FileScoreLoopCtx<'_>,
2736    node: &fallow_graph::graph::ModuleNode,
2737    path: &std::path::Path,
2738) -> FileHealthScore {
2739    let fan_in = ctx
2740        .graph
2741        .reverse_deps
2742        .get(node.file_id.0 as usize)
2743        .map_or(0, Vec::len);
2744    let fan_out = node.edge_range.len();
2745
2746    let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
2747        .module_by_id
2748        .get(&node.file_id)
2749        .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
2750
2751    let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
2752    let path_owned = path.to_path_buf();
2753    acc.value_export_counts
2754        .insert(path_owned.clone(), value_exports);
2755    record_unused_file_export_names(
2756        path_owned.as_path(),
2757        &node.exports,
2758        ctx.unused_files,
2759        &mut acc.unused_export_names,
2760    );
2761
2762    let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
2763        compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
2764
2765    let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
2766    let suppressions = ctx
2767        .module_by_id
2768        .get(&node.file_id)
2769        .map_or(&[][..], |module| module.suppressions.as_slice());
2770    let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative, suppressions);
2771    let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
2772    acc.istanbul_matched += crap.istanbul_matched;
2773    acc.istanbul_total += crap.istanbul_total;
2774    acc.istanbul_files_joined += usize::from(crap.coverage_file_joined);
2775    record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
2776    let mut eligible_functions = crap.signals.eligible_functions;
2777    if !eligible_functions.is_empty() {
2778        eligible_functions.sort_by_key(|function| std::cmp::Reverse(function.2));
2779        eligible_functions.truncate(MAX_FUNCTION_EVIDENCE);
2780        acc.crap_target_fns
2781            .insert(path_owned.clone(), eligible_functions);
2782    }
2783
2784    // `crap_effective_threshold` is the file's lowest effective ceiling, on the
2785    // wire only when it differs from the run global. Both sides come from the
2786    // same resolved configuration values, so any difference beyond epsilon is a
2787    // real override, never rounding noise.
2788    let global_crap = ctx.crap_thresholds.resolver.global.crap;
2789    let crap_effective_threshold = crap
2790        .signals
2791        .min_ceiling
2792        .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
2793
2794    FileHealthScore {
2795        path: path_owned,
2796        fan_in,
2797        fan_out,
2798        dead_code_ratio: dead_code_ratio_rounded,
2799        complexity_density: complexity_density_rounded,
2800        maintainability_index: maintainability_index_rounded,
2801        total_cyclomatic,
2802        total_cognitive,
2803        function_count,
2804        lines,
2805        crap_max: crap.max,
2806        crap_above_threshold: crap.signals.above,
2807        crap_exempted: crap.signals.exempted,
2808        crap_suppressed: crap.signals.suppressed,
2809        crap_unsuppressed_max: (crap.signals.suppressed > 0)
2810            .then_some(crap.signals.unsuppressed_max),
2811        crap_unsuppressed_effective_threshold: (crap.signals.suppressed > 0)
2812            .then_some(crap.signals.unsuppressed_min_ceiling.unwrap_or(global_crap)),
2813        crap_effective_threshold,
2814    }
2815}
2816
2817/// Compute the rounded dead-code-ratio, complexity-density, and
2818/// maintainability-index metrics for one file.
2819fn compute_file_score_metrics(
2820    node: &fallow_graph::graph::ModuleNode,
2821    path: &std::path::Path,
2822    ctx: &FileScoreLoopCtx<'_>,
2823    total_cyclomatic: u32,
2824    lines: u32,
2825    fan_out: usize,
2826) -> (f64, f64, f64) {
2827    let dead_code_ratio = compute_dead_code_ratio(
2828        path,
2829        &node.exports,
2830        ctx.unused_files,
2831        ctx.unused_exports_by_path,
2832    );
2833    let complexity_density = compute_complexity_density(total_cyclomatic, lines);
2834
2835    let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
2836    let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
2837
2838    let maintainability_index = compute_maintainability_index(
2839        complexity_density_rounded,
2840        dead_code_ratio_rounded,
2841        fan_out,
2842        lines,
2843    );
2844    (
2845        dead_code_ratio_rounded,
2846        complexity_density_rounded,
2847        (maintainability_index * 10.0).round() / 10.0,
2848    )
2849}
2850
2851fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
2852    let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
2853    let unused_deps = parts.results.unused_dependencies.len()
2854        + parts.results.unused_dev_dependencies.len()
2855        + parts.results.unused_optional_dependencies.len();
2856    let analysis_snapshot =
2857        build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
2858    let analysis_counts =
2859        build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
2860    let template_inherit_provenance =
2861        build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
2862
2863    FileScoreOutput {
2864        scores: parts.scores,
2865        coverage: parts.coverage,
2866        circular_files: parts.circular_files,
2867        top_complex_fns: parts.top_complex_fns,
2868        crap_target_fns: parts.crap_target_fns,
2869        entry_points: parts.entry_points,
2870        value_export_counts: parts.value_export_counts,
2871        unused_export_names: parts.unused_export_names,
2872        cycle_members: parts.cycle_members,
2873        direct_callers: parts.direct_callers,
2874        analysis_counts,
2875        prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
2876        render_fan_in: parts.results.render_fan_in.clone(),
2877        analysis_snapshot,
2878        istanbul_matched: parts.istanbul_matched,
2879        istanbul_total: parts.istanbul_total,
2880        istanbul_files_joined: parts.istanbul_files_joined,
2881        istanbul_files_total: parts.istanbul_files_total,
2882        coverage_input_format: parts.coverage_input_format,
2883        per_function_crap: parts.per_function_crap,
2884        template_inherit_provenance,
2885        static_links: parts
2886            .with_static_links
2887            .then(|| crate::change_coupling::StaticLinkIndex::from_graph(parts.graph)),
2888    }
2889}
2890
2891fn build_file_score_analysis_counts(
2892    results: &crate::results::AnalysisResults,
2893    total_exports: usize,
2894    unused_deps: usize,
2895) -> crate::vital_signs::AnalysisCounts {
2896    crate::vital_signs::AnalysisCounts {
2897        total_exports,
2898        dead_files: results.unused_files.len(),
2899        dead_exports: results.unused_exports.len() + results.unused_types.len(),
2900        unused_deps,
2901        circular_deps: results.circular_dependencies.len(),
2902        total_deps: 0usize,
2903    }
2904}
2905
2906fn build_template_inherit_provenance(
2907    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2908    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2909) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
2910    template_inherit
2911        .into_iter()
2912        .filter_map(|(file_id, ctx)| {
2913            file_paths
2914                .get(&file_id)
2915                .map(|path| ((**path).clone(), ctx.provenance_owner))
2916        })
2917        .collect()
2918}
2919
2920fn record_entry_point(
2921    entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
2922    node: &fallow_graph::graph::ModuleNode,
2923    path: &std::path::Path,
2924) {
2925    if node.is_entry_point() {
2926        entry_points.insert(path.to_path_buf());
2927    }
2928}
2929
2930fn record_unused_file_export_names(
2931    path: &std::path::Path,
2932    exports: &[fallow_graph::graph::ExportSymbol],
2933    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
2934    unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2935) {
2936    if !unused_files.contains(path) || unused_export_names.contains_key(path) {
2937        return;
2938    }
2939
2940    let names: Vec<String> = exports
2941        .iter()
2942        .filter(|export| !export.is_type_only)
2943        .map(|export| export.name.to_string())
2944        .collect();
2945    if !names.is_empty() {
2946        unused_export_names.insert(path.to_path_buf(), names);
2947    }
2948}
2949
2950struct FileScoreCrap {
2951    max: f64,
2952    signals: CrapThresholdSignals,
2953    per_function: Vec<PerFunctionCrap>,
2954    istanbul_matched: usize,
2955    istanbul_total: usize,
2956    /// The coverage map carried an entry for this file. Distinguishes a map
2957    /// that did not join from code the map genuinely says nothing ran in.
2958    coverage_file_joined: bool,
2959}
2960
2961impl FileScoreCrap {
2962    fn empty() -> Self {
2963        Self {
2964            max: 0.0,
2965            signals: CrapThresholdSignals::default(),
2966            per_function: Vec::new(),
2967            istanbul_matched: 0,
2968            istanbul_total: 0,
2969            coverage_file_joined: false,
2970        }
2971    }
2972
2973    fn estimated(result: EstimatedCrapResult) -> Self {
2974        Self {
2975            max: result.max_crap,
2976            signals: result.signals,
2977            per_function: result.per_function,
2978            istanbul_matched: 0,
2979            istanbul_total: 0,
2980            coverage_file_joined: false,
2981        }
2982    }
2983
2984    fn istanbul(result: IstanbulCrapResult, coverage_file_joined: bool) -> Self {
2985        Self {
2986            max: result.max_crap,
2987            signals: result.signals,
2988            per_function: result.per_function,
2989            istanbul_matched: result.matched,
2990            istanbul_total: result.total,
2991            coverage_file_joined,
2992        }
2993    }
2994}
2995
2996fn compute_file_score_crap(
2997    node: &fallow_graph::graph::ModuleNode,
2998    ctx: &FileScoreLoopCtx<'_>,
2999    path: &std::path::Path,
3000    ceilings: &CrapCeilingLookup<'_>,
3001) -> FileScoreCrap {
3002    let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
3003        return FileScoreCrap::empty();
3004    };
3005
3006    let is_coverage_suppressed = crate::suppress::is_file_suppressed(
3007        &module.suppressions,
3008        fallow_types::suppress::IssueKind::CoverageGaps,
3009    );
3010    let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
3011    let resolution = resolve_crap_coverage(
3012        ctx.template_inherit.get(&node.file_id),
3013        ctx.istanbul_coverage,
3014        path,
3015    );
3016    match resolution {
3017        CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
3018            compute_template_inherited_crap(module, inherit_ctx, ceilings)
3019        }
3020        CrapCoverageResolution::Istanbul { file_coverage } => {
3021            compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
3022        }
3023        CrapCoverageResolution::StaticEstimated => compute_static_file_crap(
3024            module,
3025            &node.exports,
3026            ctx.test_coverage,
3027            is_test_reachable,
3028            ceilings,
3029        ),
3030    }
3031}
3032
3033fn compute_template_inherited_crap(
3034    module: &crate::source::ModuleInfo,
3035    inherit_ctx: &TemplateInheritContext,
3036    ceilings: &CrapCeilingLookup<'_>,
3037) -> FileScoreCrap {
3038    FileScoreCrap::estimated(compute_crap_scores_estimated(
3039        &module.complexity,
3040        &inherit_ctx.test_referenced_exports,
3041        inherit_ctx.is_test_reachable,
3042        fallow_output::CoverageSource::EstimatedComponentInherited,
3043        ceilings,
3044    ))
3045}
3046
3047fn compute_istanbul_file_crap(
3048    module: &crate::source::ModuleInfo,
3049    file_coverage: Option<&IstanbulFileCoverage>,
3050    is_test_reachable: bool,
3051    ceilings: &CrapCeilingLookup<'_>,
3052) -> FileScoreCrap {
3053    FileScoreCrap::istanbul(
3054        compute_crap_scores_istanbul(
3055            &module.complexity,
3056            file_coverage,
3057            is_test_reachable,
3058            ceilings,
3059        ),
3060        file_coverage.is_some(),
3061    )
3062}
3063
3064fn compute_static_file_crap(
3065    module: &crate::source::ModuleInfo,
3066    exports: &[fallow_graph::graph::ExportSymbol],
3067    test_coverage: StaticTestCoverage<'_>,
3068    is_test_reachable: bool,
3069    ceilings: &CrapCeilingLookup<'_>,
3070) -> FileScoreCrap {
3071    let test_refs = build_test_referenced_exports(exports, test_coverage);
3072    FileScoreCrap::estimated(compute_crap_scores_estimated(
3073        &module.complexity,
3074        &test_refs,
3075        is_test_reachable,
3076        fallow_output::CoverageSource::Estimated,
3077        ceilings,
3078    ))
3079}
3080
3081fn record_per_function_crap(
3082    per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
3083    path: &std::path::Path,
3084    per_function: Vec<PerFunctionCrap>,
3085) {
3086    if !per_function.is_empty() {
3087        per_function_crap.insert(path.to_path_buf(), per_function);
3088    }
3089}
3090
3091struct FileScoreCoverageSetup<'a> {
3092    module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
3093    coverage: CoverageGapData,
3094}
3095
3096fn prepare_file_score_coverage_setup<'a>(
3097    modules: &'a [crate::source::ModuleInfo],
3098    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3099    results: &crate::results::AnalysisResults,
3100    graph: &fallow_graph::graph::ModuleGraph,
3101    test_coverage: StaticTestCoverage<'_>,
3102    root: &std::path::Path,
3103) -> FileScoreCoverageSetup<'a> {
3104    let module_by_id: rustc_hash::FxHashMap<_, _> =
3105        modules.iter().map(|m| (m.file_id, m)).collect();
3106    let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
3107        .unused_exports
3108        .iter()
3109        .map(|export| {
3110            (
3111                export.export.path.as_path(),
3112                export.export.export_name.clone(),
3113            )
3114        })
3115        .collect();
3116    let coverage = compute_coverage_gaps(
3117        graph,
3118        test_coverage,
3119        file_paths,
3120        &module_by_id,
3121        &unused_exports,
3122        root,
3123    );
3124    FileScoreCoverageSetup {
3125        module_by_id,
3126        coverage,
3127    }
3128}
3129
3130fn collect_circular_files(
3131    results: &crate::results::AnalysisResults,
3132) -> rustc_hash::FxHashSet<std::path::PathBuf> {
3133    results
3134        .circular_dependencies
3135        .iter()
3136        .flat_map(|c| c.cycle.files.iter().cloned())
3137        .collect()
3138}
3139
3140fn collect_top_complex_fns(
3141    modules: &[crate::source::ModuleInfo],
3142    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3143) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
3144    let mut top_complex_fns = rustc_hash::FxHashMap::default();
3145    for module in modules {
3146        if module.complexity.is_empty() {
3147            continue;
3148        }
3149        let Some(path) = file_paths.get(&module.file_id) else {
3150            continue;
3151        };
3152        // The module-scope unit is excluded: this list feeds the refactoring
3153        // target's evidence copy, which would otherwise render
3154        // "<module> has cognitive complexity 12" next to advice about
3155        // extracting helpers.
3156        let mut funcs: Vec<(String, u32, u16)> = module
3157            .complexity
3158            .iter()
3159            .filter(|f| !fallow_types::extract::is_synthetic_module_unit(&f.name))
3160            .map(|f| (f.name.clone(), f.line, f.cognitive))
3161            .collect();
3162        funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
3163        funcs.truncate(MAX_FUNCTION_EVIDENCE);
3164        if funcs.first().is_some_and(|worst| worst.2 > 0) {
3165            top_complex_fns.insert((*path).clone(), funcs);
3166        }
3167    }
3168    top_complex_fns
3169}
3170
3171fn collect_cycle_members(
3172    results: &crate::results::AnalysisResults,
3173) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
3174    let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
3175        rustc_hash::FxHashMap::default();
3176    for cycle in &results.circular_dependencies {
3177        for file in &cycle.cycle.files {
3178            let others: Vec<std::path::PathBuf> = cycle
3179                .cycle
3180                .files
3181                .iter()
3182                .filter(|f| *f != file)
3183                .cloned()
3184                .collect();
3185            cycle_members
3186                .entry(file.clone())
3187                .or_default()
3188                .extend(others);
3189        }
3190    }
3191    for members in cycle_members.values_mut() {
3192        members.sort();
3193        members.dedup();
3194    }
3195    cycle_members
3196}
3197
3198fn collect_unused_export_names(
3199    results: &crate::results::AnalysisResults,
3200) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
3201    let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
3202        rustc_hash::FxHashMap::default();
3203    for exp in &results.unused_exports {
3204        unused_export_names
3205            .entry(exp.export.path.clone())
3206            .or_default()
3207            .push(exp.export.export_name.clone());
3208    }
3209    unused_export_names
3210}
3211
3212fn build_analysis_counts_snapshot(
3213    graph: &fallow_graph::graph::ModuleGraph,
3214    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3215    results: &crate::results::AnalysisResults,
3216    unused_deps: usize,
3217) -> AnalysisCountsSnapshot {
3218    let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
3219        graph.modules.len(),
3220        rustc_hash::FxBuildHasher,
3221    );
3222    for module in &graph.modules {
3223        if let Some(path) = file_paths.get(&module.file_id) {
3224            module_export_counts.insert((*path).clone(), module.exports.len());
3225        }
3226    }
3227
3228    let mut unused_export_paths =
3229        Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
3230    unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
3231    unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
3232
3233    let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
3234    unused_dep_package_paths.extend(
3235        results
3236            .unused_dependencies
3237            .iter()
3238            .map(|d| d.dep.path.clone()),
3239    );
3240    unused_dep_package_paths.extend(
3241        results
3242            .unused_dev_dependencies
3243            .iter()
3244            .map(|d| d.dep.path.clone()),
3245    );
3246    unused_dep_package_paths.extend(
3247        results
3248            .unused_optional_dependencies
3249            .iter()
3250            .map(|d| d.dep.path.clone()),
3251    );
3252
3253    AnalysisCountsSnapshot {
3254        unused_file_paths: results
3255            .unused_files
3256            .iter()
3257            .map(|f| f.file.path.clone())
3258            .collect(),
3259        unused_export_paths,
3260        unused_dep_package_paths,
3261        circular_dep_groups: results
3262            .circular_dependencies
3263            .iter()
3264            .map(|c| c.cycle.files.clone())
3265            .collect(),
3266        module_export_counts,
3267    }
3268}
3269
3270#[cfg(test)]
3271mod tests {
3272    use super::super::threshold_overrides::GlobalHealthThresholds;
3273    use super::*;
3274
3275    /// Resolver with no override entries and the given global CRAP ceiling,
3276    /// the default-configuration shape for scoring tests.
3277    fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
3278        ThresholdOverrideResolver::new(
3279            &[],
3280            GlobalHealthThresholds {
3281                cyclomatic: 20,
3282                cognitive: 15,
3283                crap,
3284                unit_size: 120,
3285            },
3286        )
3287    }
3288
3289    /// Resolver with the given override entries over the default global 30.0.
3290    fn test_override_resolver(
3291        overrides: &[fallow_config::HealthThresholdOverride],
3292    ) -> ThresholdOverrideResolver {
3293        ThresholdOverrideResolver::new(
3294            overrides,
3295            GlobalHealthThresholds {
3296                cyclomatic: 20,
3297                cognitive: 15,
3298                crap: CRAP_THRESHOLD,
3299                unit_size: 120,
3300            },
3301        )
3302    }
3303
3304    /// `compute_crap_scores_istanbul` with default-configuration ceilings.
3305    fn istanbul_crap_default(
3306        complexity: &[fallow_types::extract::FunctionComplexity],
3307        file_coverage: Option<&IstanbulFileCoverage>,
3308        is_test_reachable: bool,
3309    ) -> IstanbulCrapResult {
3310        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3311        let ceilings = CrapCeilingLookup::new(
3312            CrapScoreThresholds {
3313                resolver: &resolver,
3314                enforce_crap: true,
3315            },
3316            std::path::Path::new("src/test.ts"),
3317            &[],
3318        );
3319        compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
3320    }
3321
3322    /// A coverage map that says nothing about a function is not evidence that
3323    /// the function ran, so passing one must not score it lower than the run
3324    /// without a map would have. Both paths use the same static estimate for a
3325    /// function whose file tests reach.
3326    #[test]
3327    fn an_unmatched_function_scores_the_same_with_and_without_a_coverage_map() {
3328        let temp = tempfile::TempDir::new().unwrap();
3329        let source_path = temp.path().join("src/grade.ts");
3330        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
3331        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
3332
3333        // A map that carries the file but records a function fallow never
3334        // extracted, which is what a stale map or an unresolved producer
3335        // anchor produces.
3336        let coverage_path = temp.path().join("coverage-final.json");
3337        write_single_file_istanbul_fixture(
3338            &coverage_path,
3339            &source_path,
3340            &serde_json::json!({
3341                "0": {
3342                    "name": "unrelated",
3343                    "line": 40,
3344                    "decl": { "start": { "line": 40, "column": 0 }, "end": { "line": 40, "column": 9 } },
3345                    "loc": { "start": { "line": 40, "column": 20 }, "end": { "line": 44, "column": 1 } }
3346                }
3347            }),
3348            &serde_json::json!({ "0": 1 }),
3349        );
3350        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
3351        let canonical_source = dunce::canonicalize(&source_path).unwrap();
3352        let file_coverage = coverage.get(&canonical_source).unwrap();
3353
3354        let function = make_fn_complexity(10);
3355        let with_map =
3356            istanbul_crap_default(std::slice::from_ref(&function), Some(file_coverage), true);
3357        let estimated = compute_crap_scores_estimated(
3358            std::slice::from_ref(&function),
3359            &rustc_hash::FxHashSet::default(),
3360            true,
3361            fallow_output::CoverageSource::Estimated,
3362            &CrapCeilingLookup::new(
3363                CrapScoreThresholds {
3364                    resolver: &test_crap_resolver(CRAP_THRESHOLD),
3365                    enforce_crap: true,
3366                },
3367                std::path::Path::new("src/test.ts"),
3368                &[],
3369            ),
3370        );
3371
3372        assert_eq!(with_map.matched, 0);
3373        assert!(
3374            (with_map.per_function[0].crap - estimated.per_function[0].crap).abs() < f64::EPSILON,
3375            "a map that attributes nothing must not change the score"
3376        );
3377        assert_eq!(with_map.per_function[0].coverage_pct, None);
3378    }
3379
3380    fn test_istanbul_file_coverage(
3381        functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
3382        relocated: bool,
3383    ) -> IstanbulFileCoverage {
3384        let functions = functions
3385            .into_iter()
3386            .map(
3387                |((name, line, col), coverage_pct)| IstanbulFunctionCoverage {
3388                    name,
3389                    coverage_pct,
3390                    aliases: vec![primary_alias(line, col)],
3391                    decl_start: IstanbulPosition::new(line, col),
3392                    header_holds_other_fn: false,
3393                    header_span: None,
3394                    body_span: None,
3395                },
3396            )
3397            .collect();
3398        IstanbulFileCoverage::new(functions, relocated)
3399    }
3400
3401    fn primary_alias(line: u32, col: u32) -> IstanbulAlias {
3402        IstanbulAlias {
3403            position: IstanbulPosition::new(line, col),
3404            primary: true,
3405        }
3406    }
3407
3408    fn secondary_alias(line: u32, col: u32) -> IstanbulAlias {
3409        IstanbulAlias {
3410            position: IstanbulPosition::new(line, col),
3411            primary: false,
3412        }
3413    }
3414
3415    fn body_span(start: (u32, u32), end: (u32, u32)) -> IstanbulSpan {
3416        IstanbulSpan {
3417            start: IstanbulPosition::new(start.0, start.1),
3418            end: IstanbulPosition::new(end.0, end.1),
3419        }
3420    }
3421
3422    /// `compute_crap_scores_estimated` with default-configuration ceilings.
3423    fn estimated_crap_default(
3424        complexity: &[fallow_types::extract::FunctionComplexity],
3425        test_referenced_exports: &rustc_hash::FxHashSet<String>,
3426        is_test_reachable: bool,
3427        coverage_source: fallow_output::CoverageSource,
3428    ) -> EstimatedCrapResult {
3429        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3430        let ceilings = CrapCeilingLookup::new(
3431            CrapScoreThresholds {
3432                resolver: &resolver,
3433                enforce_crap: true,
3434            },
3435            std::path::Path::new("src/test.ts"),
3436            &[],
3437        );
3438        compute_crap_scores_estimated(
3439            complexity,
3440            test_referenced_exports,
3441            is_test_reachable,
3442            coverage_source,
3443            &ceilings,
3444        )
3445    }
3446
3447    /// `compute_file_scores` with default-configuration CRAP thresholds.
3448    fn compute_file_scores_default(
3449        modules: &[crate::source::ModuleInfo],
3450        file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3451        changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3452        analysis_output: crate::results::DeadCodeAnalysisArtifacts,
3453        istanbul_coverage: Option<&IstanbulCoverage>,
3454        root: &std::path::Path,
3455    ) -> Result<FileScoreOutput, String> {
3456        let resolver = test_crap_resolver(CRAP_THRESHOLD);
3457        compute_file_scores(
3458            FileScoreComputeInput {
3459                modules,
3460                file_paths,
3461                changed_files,
3462                istanbul_coverage,
3463                root,
3464                crap_thresholds: CrapScoreThresholds {
3465                    resolver: &resolver,
3466                    enforce_crap: true,
3467                },
3468                with_static_links: false,
3469            },
3470            analysis_output,
3471        )
3472    }
3473
3474    #[test]
3475    fn maintainability_perfect_score() {
3476        assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
3477    }
3478
3479    #[test]
3480    fn crap_resolution_prefers_template_inheritance_over_istanbul() {
3481        let inherit_ctx = TemplateInheritContext {
3482            is_test_reachable: true,
3483            test_referenced_exports: rustc_hash::FxHashSet::default(),
3484            provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
3485        };
3486        let istanbul = IstanbulCoverage {
3487            files: rustc_hash::FxHashMap::default(),
3488            format: fallow_output::CoverageInputFormat::Istanbul,
3489        };
3490
3491        let resolution = resolve_crap_coverage(
3492            Some(&inherit_ctx),
3493            Some(&istanbul),
3494            std::path::Path::new("/project/src/app.component.html"),
3495        );
3496
3497        assert!(matches!(
3498            resolution,
3499            CrapCoverageResolution::TemplateInherited(_)
3500        ));
3501    }
3502
3503    #[test]
3504    fn crap_resolution_keeps_istanbul_when_file_is_missing() {
3505        let istanbul = IstanbulCoverage {
3506            files: rustc_hash::FxHashMap::default(),
3507            format: fallow_output::CoverageInputFormat::Istanbul,
3508        };
3509
3510        let resolution = resolve_crap_coverage(
3511            None,
3512            Some(&istanbul),
3513            std::path::Path::new("/project/src/missing.ts"),
3514        );
3515
3516        assert!(matches!(
3517            resolution,
3518            CrapCoverageResolution::Istanbul {
3519                file_coverage: None
3520            }
3521        ));
3522    }
3523
3524    #[test]
3525    fn maintainability_clamped_at_zero() {
3526        assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
3527    }
3528
3529    #[test]
3530    fn maintainability_formula_correct() {
3531        let result = compute_maintainability_index(0.5, 0.3, 10, 100);
3532        let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
3533        assert!((result - expected).abs() < 0.01);
3534    }
3535
3536    #[test]
3537    fn maintainability_dead_file_penalty() {
3538        let result = compute_maintainability_index(0.0, 1.0, 0, 100);
3539        assert!((result - 80.0).abs() < f64::EPSILON);
3540    }
3541
3542    #[test]
3543    fn maintainability_fan_out_is_logarithmic() {
3544        let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
3545        let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
3546        let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
3547
3548        assert!(result_10 > 90.0); // ~90.4
3549        assert!(result_100 > 84.0); // 85.0 (capped)
3550        assert!((result_100 - result_200).abs() < f64::EPSILON);
3551    }
3552
3553    #[test]
3554    fn maintainability_fan_out_capped_at_15() {
3555        let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
3556        assert!((result - 65.0).abs() < f64::EPSILON);
3557    }
3558
3559    #[test]
3560    fn maintainability_small_file_dampened() {
3561        let small = compute_maintainability_index(0.40, 0.0, 0, 5);
3562        assert!((small - 98.8).abs() < 0.01);
3563    }
3564
3565    #[test]
3566    fn maintainability_large_file_undampened() {
3567        let large = compute_maintainability_index(0.30, 0.0, 0, 192);
3568        assert!((large - 91.0).abs() < 0.01);
3569    }
3570
3571    #[test]
3572    fn maintainability_small_file_ranks_better_than_complex_large_file() {
3573        let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
3574        let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
3575        assert!(
3576            trivial > nightmare,
3577            "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
3578        );
3579    }
3580
3581    #[test]
3582    fn maintainability_at_dampening_boundary() {
3583        let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
3584        let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
3585        assert!((at_boundary - above_boundary).abs() < 0.01);
3586    }
3587
3588    #[test]
3589    fn maintainability_zero_lines_zero_density_penalty() {
3590        let result = compute_maintainability_index(5.0, 0.0, 0, 0);
3591        assert!((result - 100.0).abs() < f64::EPSILON);
3592    }
3593
3594    #[test]
3595    fn complexity_density_zero_lines() {
3596        assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
3597    }
3598
3599    #[test]
3600    fn complexity_density_normal() {
3601        let result = compute_complexity_density(10, 100);
3602        assert!((result - 0.1).abs() < f64::EPSILON);
3603    }
3604
3605    #[test]
3606    fn complexity_density_high() {
3607        let result = compute_complexity_density(50, 10);
3608        assert!((result - 5.0).abs() < f64::EPSILON);
3609    }
3610
3611    #[test]
3612    fn dead_code_ratio_no_exports() {
3613        let unused_files = rustc_hash::FxHashSet::default();
3614        let unused_map = rustc_hash::FxHashMap::default();
3615        let path = std::path::Path::new("/src/foo.ts");
3616        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3617
3618        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3619        assert!((ratio).abs() < f64::EPSILON);
3620    }
3621
3622    #[test]
3623    fn dead_code_ratio_all_unused_file() {
3624        let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
3625            rustc_hash::FxHashSet::default();
3626        let path = std::path::Path::new("/src/foo.ts");
3627        unused_files.insert(path);
3628        let unused_map = rustc_hash::FxHashMap::default();
3629        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3630
3631        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3632        assert!((ratio - 1.0).abs() < f64::EPSILON);
3633    }
3634
3635    #[test]
3636    fn dead_code_ratio_mix() {
3637        let unused_files = rustc_hash::FxHashSet::default();
3638        let path = std::path::Path::new("/src/foo.ts");
3639
3640        let exports = vec![
3641            fallow_graph::graph::ExportSymbol {
3642                name: crate::source::ExportName::Named("a".into()),
3643                is_type_only: false,
3644                is_side_effect_used: false,
3645                visibility: crate::source::VisibilityTag::None,
3646                expected_unused_reason: None,
3647                span: oxc_span::Span::empty(0),
3648                references: vec![],
3649                reference_paths: Vec::new(),
3650                members: vec![],
3651                deprecated: false,
3652                deprecated_reason: None,
3653            },
3654            fallow_graph::graph::ExportSymbol {
3655                name: crate::source::ExportName::Named("b".into()),
3656                is_type_only: false,
3657                is_side_effect_used: false,
3658                visibility: crate::source::VisibilityTag::None,
3659                expected_unused_reason: None,
3660                span: oxc_span::Span::empty(0),
3661                references: vec![],
3662                reference_paths: Vec::new(),
3663                members: vec![],
3664                deprecated: false,
3665                deprecated_reason: None,
3666            },
3667            fallow_graph::graph::ExportSymbol {
3668                name: crate::source::ExportName::Named("c".into()),
3669                is_type_only: false,
3670                is_side_effect_used: false,
3671                visibility: crate::source::VisibilityTag::None,
3672                expected_unused_reason: None,
3673                span: oxc_span::Span::empty(0),
3674                references: vec![],
3675                reference_paths: Vec::new(),
3676                members: vec![],
3677                deprecated: false,
3678                deprecated_reason: None,
3679            },
3680            fallow_graph::graph::ExportSymbol {
3681                name: crate::source::ExportName::Named("MyType".into()),
3682                is_type_only: true,
3683                is_side_effect_used: false,
3684                visibility: crate::source::VisibilityTag::None,
3685                expected_unused_reason: None,
3686                span: oxc_span::Span::empty(0),
3687                references: vec![],
3688                reference_paths: Vec::new(),
3689                members: vec![],
3690                deprecated: false,
3691                deprecated_reason: None,
3692            },
3693        ];
3694
3695        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3696            rustc_hash::FxHashMap::default();
3697        unused_map.insert(path, 2);
3698
3699        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3700        assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
3701    }
3702
3703    #[test]
3704    fn dead_code_ratio_all_type_only_exports() {
3705        let unused_files = rustc_hash::FxHashSet::default();
3706        let path = std::path::Path::new("/src/types.ts");
3707
3708        let exports = vec![fallow_graph::graph::ExportSymbol {
3709            name: crate::source::ExportName::Named("Foo".into()),
3710            is_type_only: true,
3711            is_side_effect_used: false,
3712            visibility: crate::source::VisibilityTag::None,
3713            expected_unused_reason: None,
3714            span: oxc_span::Span::empty(0),
3715            references: vec![],
3716            reference_paths: Vec::new(),
3717            members: vec![],
3718            deprecated: false,
3719            deprecated_reason: None,
3720        }];
3721        let unused_map = rustc_hash::FxHashMap::default();
3722
3723        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3724        assert!((ratio).abs() < f64::EPSILON);
3725    }
3726
3727    #[test]
3728    fn aggregate_complexity_empty_module() {
3729        let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
3730
3731        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3732        assert_eq!(cyc, 0);
3733        assert_eq!(cog, 0);
3734        assert_eq!(funcs, 0);
3735        assert_eq!(lines, 0);
3736    }
3737
3738    #[test]
3739    fn aggregate_complexity_single_function() {
3740        let module = crate::source::ModuleInfo {
3741            line_offsets: vec![0, 10, 20, 30, 40], // 5 lines
3742            complexity: vec![fallow_types::extract::FunctionComplexity {
3743                name: "doStuff".into(),
3744                is_private_member: false,
3745                context_named: None,
3746                line: 1,
3747                col: 0,
3748                cyclomatic: 7,
3749                cognitive: 4,
3750                line_count: 5,
3751                param_count: 0,
3752                react_hook_count: 0,
3753                react_jsx_max_depth: 0,
3754                react_prop_count: 0,
3755                source_hash: None,
3756                contributions: Vec::new(),
3757            }],
3758            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3759        };
3760
3761        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3762        assert_eq!(cyc, 7);
3763        assert_eq!(cog, 4);
3764        assert_eq!(funcs, 1);
3765        assert_eq!(lines, 5);
3766    }
3767
3768    #[test]
3769    fn aggregate_complexity_multiple_functions() {
3770        let module = crate::source::ModuleInfo {
3771            line_offsets: vec![0, 10, 20], // 3 lines
3772            complexity: vec![
3773                fallow_types::extract::FunctionComplexity {
3774                    name: "a".into(),
3775                    is_private_member: false,
3776                    context_named: None,
3777                    line: 1,
3778                    col: 0,
3779                    cyclomatic: 3,
3780                    cognitive: 2,
3781                    line_count: 1,
3782                    param_count: 0,
3783                    react_hook_count: 0,
3784                    react_jsx_max_depth: 0,
3785                    react_prop_count: 0,
3786                    source_hash: None,
3787                    contributions: Vec::new(),
3788                },
3789                fallow_types::extract::FunctionComplexity {
3790                    name: "b".into(),
3791                    is_private_member: false,
3792                    context_named: None,
3793                    line: 2,
3794                    col: 0,
3795                    cyclomatic: 5,
3796                    cognitive: 8,
3797                    line_count: 2,
3798                    param_count: 0,
3799                    react_hook_count: 0,
3800                    react_jsx_max_depth: 0,
3801                    react_prop_count: 0,
3802                    source_hash: None,
3803                    contributions: Vec::new(),
3804                },
3805            ],
3806            ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3807        };
3808
3809        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3810        assert_eq!(cyc, 8);
3811        assert_eq!(cog, 10);
3812        assert_eq!(funcs, 2);
3813        assert_eq!(lines, 3);
3814    }
3815
3816    #[test]
3817    fn count_unused_exports_empty() {
3818        let exports: Vec<crate::results::UnusedExportFinding> = vec![];
3819        let map = count_unused_exports_by_path(&exports);
3820        assert!(map.is_empty());
3821    }
3822
3823    #[test]
3824    fn count_unused_exports_groups_by_path() {
3825        let exports = vec![
3826            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3827                path: std::path::PathBuf::from("/src/a.ts"),
3828                export_name: "foo".into(),
3829                is_type_only: false,
3830                line: 1,
3831                col: 0,
3832                span_start: 0,
3833                is_re_export: false,
3834                deprecated: false,
3835                deprecated_reason: None,
3836            }),
3837            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3838                path: std::path::PathBuf::from("/src/a.ts"),
3839                export_name: "bar".into(),
3840                is_type_only: false,
3841                line: 5,
3842                col: 0,
3843                span_start: 40,
3844                is_re_export: false,
3845                deprecated: false,
3846                deprecated_reason: None,
3847            }),
3848            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3849                path: std::path::PathBuf::from("/src/b.ts"),
3850                export_name: "baz".into(),
3851                is_type_only: false,
3852                line: 1,
3853                col: 0,
3854                span_start: 0,
3855                is_re_export: false,
3856                deprecated: false,
3857                deprecated_reason: None,
3858            }),
3859        ];
3860        let map = count_unused_exports_by_path(&exports);
3861        assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
3862        assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
3863    }
3864
3865    #[test]
3866    fn dead_code_ratio_all_value_exports_unused() {
3867        let unused_files = rustc_hash::FxHashSet::default();
3868        let path = std::path::Path::new("/src/foo.ts");
3869
3870        let exports = vec![
3871            fallow_graph::graph::ExportSymbol {
3872                name: crate::source::ExportName::Named("a".into()),
3873                is_type_only: false,
3874                is_side_effect_used: false,
3875                visibility: crate::source::VisibilityTag::None,
3876                expected_unused_reason: None,
3877                span: oxc_span::Span::empty(0),
3878                references: vec![],
3879                reference_paths: Vec::new(),
3880                members: vec![],
3881                deprecated: false,
3882                deprecated_reason: None,
3883            },
3884            fallow_graph::graph::ExportSymbol {
3885                name: crate::source::ExportName::Named("b".into()),
3886                is_type_only: false,
3887                is_side_effect_used: false,
3888                visibility: crate::source::VisibilityTag::None,
3889                expected_unused_reason: None,
3890                span: oxc_span::Span::empty(0),
3891                references: vec![],
3892                reference_paths: Vec::new(),
3893                members: vec![],
3894                deprecated: false,
3895                deprecated_reason: None,
3896            },
3897        ];
3898
3899        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3900            rustc_hash::FxHashMap::default();
3901        unused_map.insert(path, 2);
3902
3903        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3904        assert!((ratio - 1.0).abs() < f64::EPSILON);
3905    }
3906
3907    #[test]
3908    fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
3909        let unused_files = rustc_hash::FxHashSet::default();
3910        let path = std::path::Path::new("/src/foo.ts");
3911
3912        let exports = vec![fallow_graph::graph::ExportSymbol {
3913            name: crate::source::ExportName::Named("a".into()),
3914            is_type_only: false,
3915            is_side_effect_used: false,
3916            visibility: crate::source::VisibilityTag::None,
3917            expected_unused_reason: None,
3918            span: oxc_span::Span::empty(0),
3919            references: vec![],
3920            reference_paths: Vec::new(),
3921            members: vec![],
3922            deprecated: false,
3923            deprecated_reason: None,
3924        }];
3925
3926        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3927            rustc_hash::FxHashMap::default();
3928        unused_map.insert(path, 5);
3929
3930        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3931        assert!((ratio - 1.0).abs() < f64::EPSILON);
3932    }
3933
3934    #[test]
3935    fn dead_code_ratio_no_unused_exports_for_path() {
3936        let unused_files = rustc_hash::FxHashSet::default();
3937        let path = std::path::Path::new("/src/clean.ts");
3938
3939        let exports = vec![fallow_graph::graph::ExportSymbol {
3940            name: crate::source::ExportName::Named("used".into()),
3941            is_type_only: false,
3942            is_side_effect_used: false,
3943            visibility: crate::source::VisibilityTag::None,
3944            expected_unused_reason: None,
3945            span: oxc_span::Span::empty(0),
3946            references: vec![],
3947            reference_paths: Vec::new(),
3948            members: vec![],
3949            deprecated: false,
3950            deprecated_reason: None,
3951        }];
3952
3953        let unused_map = rustc_hash::FxHashMap::default();
3954        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3955        assert!(ratio.abs() < f64::EPSILON);
3956    }
3957
3958    #[test]
3959    fn complexity_density_zero_cyclomatic_with_lines() {
3960        let result = compute_complexity_density(0, 100);
3961        assert!(result.abs() < f64::EPSILON);
3962    }
3963
3964    #[test]
3965    fn complexity_density_single_line() {
3966        let result = compute_complexity_density(1, 1);
3967        assert!((result - 1.0).abs() < f64::EPSILON);
3968    }
3969
3970    #[test]
3971    fn maintainability_only_complexity_penalty() {
3972        let result = compute_maintainability_index(3.0, 0.0, 0, 100);
3973        assert!((result - 10.0).abs() < f64::EPSILON);
3974    }
3975
3976    #[test]
3977    fn maintainability_only_dead_code_penalty() {
3978        let result = compute_maintainability_index(0.0, 0.5, 0, 100);
3979        assert!((result - 90.0).abs() < f64::EPSILON);
3980    }
3981
3982    #[test]
3983    fn maintainability_fan_out_one() {
3984        let result = compute_maintainability_index(0.0, 0.0, 1, 100);
3985        let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
3986        assert!((result - expected).abs() < 0.01);
3987    }
3988
3989    #[test]
3990    fn maintainability_all_penalties_maxed() {
3991        let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
3992        assert!(result.abs() < f64::EPSILON);
3993    }
3994
3995    #[test]
3996    fn count_unused_exports_single_file_single_export() {
3997        let exports = vec![crate::results::UnusedExportFinding::with_actions(
3998            crate::results::UnusedExport {
3999                path: std::path::PathBuf::from("/src/only.ts"),
4000                export_name: "lonely".into(),
4001                is_type_only: false,
4002                line: 1,
4003                col: 0,
4004                span_start: 0,
4005                is_re_export: false,
4006                deprecated: false,
4007                deprecated_reason: None,
4008            },
4009        )];
4010        let map = count_unused_exports_by_path(&exports);
4011        assert_eq!(map.len(), 1);
4012        assert_eq!(
4013            map.get(std::path::Path::new("/src/only.ts")).copied(),
4014            Some(1)
4015        );
4016    }
4017
4018    /// Helper to build a minimal `ModuleGraph` from scratch.
4019    fn build_test_graph(
4020        files: &[crate::discover::DiscoveredFile],
4021        entry_point_paths: &[std::path::PathBuf],
4022        resolved_modules: &[fallow_graph::resolve::ResolvedModule],
4023    ) -> fallow_graph::graph::ModuleGraph {
4024        let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
4025            .iter()
4026            .map(|p| crate::discover::EntryPoint {
4027                path: p.clone(),
4028                source: crate::discover::EntryPointSource::PackageJsonMain,
4029            })
4030            .collect();
4031        fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
4032    }
4033
4034    /// Helper to create a `ModuleInfo` with given complexity and line count.
4035    fn make_module_info(
4036        file_id: u32,
4037        line_count: usize,
4038        functions: Vec<fallow_types::extract::FunctionComplexity>,
4039    ) -> crate::source::ModuleInfo {
4040        crate::source::ModuleInfo {
4041            line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
4042            complexity: functions,
4043            ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
4044        }
4045    }
4046
4047    fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
4048        FileHealthScore {
4049            path: std::path::PathBuf::from(path),
4050            fan_in: 0,
4051            fan_out: 0,
4052            dead_code_ratio: 0.0,
4053            complexity_density: 0.0,
4054            maintainability_index,
4055            total_cyclomatic: 0,
4056            total_cognitive: 0,
4057            function_count: 1,
4058            lines: 1,
4059            crap_max,
4060            crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
4061            crap_exempted: 0,
4062            crap_suppressed: 0,
4063            crap_unsuppressed_max: None,
4064            crap_unsuppressed_effective_threshold: None,
4065            crap_effective_threshold: None,
4066        }
4067    }
4068
4069    #[test]
4070    fn suppressed_crap_keeps_unsuppressed_bands_and_legacy_fallback() {
4071        for (remaining, threshold, above, exempted, global, expected) in [
4072            (20.0, 30.0, 0, 1, 30.0, 55.0),
4073            (20.0, 30.0, 0, 0, 30.0, 55.0),
4074            (0.0, 30.0, 0, 0, 30.0, 0.0),
4075            (110.0, 500.0, 0, 1, 30.0, 0.0),
4076            (42.0, 0.0, 0, 2, 0.0, 0.0),
4077            (20.0, 5.0, 1, 0, 5.0, 100.0),
4078        ] {
4079            let mut score = make_file_score("src/legacy.ts", 100.0, 210.0);
4080            score.crap_suppressed = 1;
4081            score.crap_above_threshold = above;
4082            score.crap_exempted = exempted;
4083            score.crap_effective_threshold = Some(5.0);
4084            score.crap_unsuppressed_max = Some(remaining);
4085            score.crap_unsuppressed_effective_threshold = Some(threshold);
4086            let restored: FileHealthScore =
4087                serde_json::from_value(serde_json::to_value(&score).unwrap()).unwrap();
4088            assert!((file_score_crap_concern(&restored, global) - expected).abs() < f64::EPSILON);
4089            assert_eq!(restored.crap_max, 210.0);
4090            assert_eq!(restored.crap_effective_threshold, Some(5.0));
4091            assert_eq!(restored.crap_exempted, exempted);
4092        }
4093        let mut legacy = make_file_score("src/legacy.ts", 100.0, 20.0);
4094        legacy.crap_effective_threshold = Some(10.0);
4095        for (maximum, threshold) in [(None, None), (Some(0.0), None), (None, Some(500.0))] {
4096            legacy.crap_unsuppressed_max = maximum;
4097            legacy.crap_unsuppressed_effective_threshold = threshold;
4098            let restored: FileHealthScore =
4099                serde_json::from_value(serde_json::to_value(&legacy).unwrap()).unwrap();
4100            let signal = file_score_crap_signal(&restored, 30.0);
4101            assert_eq!(signal.maximum, 20.0);
4102            assert_eq!(signal.threshold, 10.0);
4103            assert_eq!(signal.metric(), "crap_max");
4104            assert!(
4105                (file_score_crap_concern(&restored, 30.0) - 85.714_285_714_285_71).abs() < 1e-10
4106            );
4107        }
4108    }
4109
4110    #[test]
4111    fn suppressed_crap_maximum_does_not_win_the_triage_tie_break() {
4112        let mut suppressed = make_file_score("src/a.ts", 0.0, 210.0);
4113        suppressed.crap_suppressed = 1;
4114        suppressed.crap_unsuppressed_max = Some(42.0);
4115        suppressed.crap_unsuppressed_effective_threshold = Some(30.0);
4116        let other = make_file_score("src/b.ts", 0.0, 50.0);
4117        assert_eq!(
4118            compare_file_score_triage(&suppressed, &other, 30.0),
4119            std::cmp::Ordering::Greater
4120        );
4121        suppressed.crap_unsuppressed_max = Some(60.0);
4122        assert_eq!(
4123            compare_file_score_triage(&suppressed, &other, 30.0),
4124            std::cmp::Ordering::Less
4125        );
4126    }
4127
4128    #[test]
4129    fn inherited_crap_suppression_keeps_raw_function_measurements() {
4130        let mut module = make_module_info(
4131            0,
4132            30,
4133            vec![
4134                make_named_fn_complexity("suppressed", 1, 14),
4135                make_named_fn_complexity("remaining", 20, 6),
4136            ],
4137        );
4138        let resolver = test_crap_resolver(30.0);
4139        let context = TemplateInheritContext {
4140            is_test_reachable: false,
4141            test_referenced_exports: rustc_hash::FxHashSet::default(),
4142            provenance_owner: std::path::PathBuf::from("src/owner.ts"),
4143        };
4144        for (suppressed, expected_above) in [(false, 2), (true, 1)] {
4145            module.suppressions = if suppressed {
4146                vec![crate::suppress::Suppression::issue(
4147                    1,
4148                    1,
4149                    crate::suppress::IssueKind::Complexity,
4150                )]
4151            } else {
4152                Vec::new()
4153            };
4154            let lookup = CrapCeilingLookup::new(
4155                CrapScoreThresholds {
4156                    resolver: &resolver,
4157                    enforce_crap: true,
4158                },
4159                std::path::Path::new("src/template.html"),
4160                &module.suppressions,
4161            );
4162            let result = compute_template_inherited_crap(&module, &context, &lookup);
4163            assert_eq!(result.max, 210.0);
4164            assert_eq!(result.signals.above, expected_above);
4165            assert_eq!(result.signals.suppressed, usize::from(suppressed));
4166            assert_eq!(
4167                result.signals.unsuppressed_max,
4168                if suppressed { 42.0 } else { 210.0 }
4169            );
4170            let raw = result
4171                .per_function
4172                .iter()
4173                .map(|function| (function.line, function.crap))
4174                .collect::<Vec<_>>();
4175            assert_eq!(raw, [(1, 210.0), (20, 42.0)]);
4176            assert!(
4177                result
4178                    .per_function
4179                    .iter()
4180                    .all(|function| function.coverage_source
4181                        == fallow_output::CoverageSource::EstimatedComponentInherited)
4182            );
4183        }
4184    }
4185
4186    fn crap_concern_at_default(crap_max: f64) -> f64 {
4187        file_score_crap_concern(
4188            &make_file_score("/src/concern.ts", 100.0, crap_max),
4189            CRAP_THRESHOLD,
4190        )
4191    }
4192
4193    #[test]
4194    fn file_score_crap_concern_tracks_crap_risk_bands() {
4195        assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
4196        assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
4197        assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
4198        assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
4199        assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
4200    }
4201
4202    #[test]
4203    fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
4204        // At t = 500 the band edges scale to (250, 500, 1666.7): a breaching
4205        // 250 sits at the moderate/high edge and a breaching 500 at high.
4206        let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
4207        at_edge.crap_above_threshold = 1;
4208        at_edge.crap_effective_threshold = Some(500.0);
4209        assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
4210
4211        let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
4212        at_ceiling.crap_above_threshold = 1;
4213        at_ceiling.crap_effective_threshold = Some(500.0);
4214        assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
4215    }
4216
4217    #[test]
4218    fn file_score_crap_concern_zeroes_fully_exempt_file() {
4219        // The issue's own numbers: crap_max 110 under ceiling 500 with both
4220        // breaches exempted. Band generalization alone would yield 19.8 and
4221        // keep the row above a structural concern of 12; the fully-exempt rule
4222        // must zero it (issue #2228).
4223        let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
4224        exempt.crap_above_threshold = 0;
4225        exempt.crap_exempted = 2;
4226        exempt.crap_effective_threshold = Some(500.0);
4227        assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
4228        assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
4229        assert_eq!(
4230            file_score_concern_axis(&exempt, CRAP_THRESHOLD),
4231            FileScoreConcern::Structural
4232        );
4233    }
4234
4235    #[test]
4236    fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
4237        let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
4238        score.crap_above_threshold = 0;
4239        score.crap_exempted = 2;
4240        assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
4241        assert!(file_score_fully_crap_exempt(&score, 0.0));
4242        assert_eq!(
4243            file_score_concern_axis(&score, 0.0),
4244            FileScoreConcern::Structural
4245        );
4246    }
4247
4248    #[test]
4249    fn file_score_partial_exemption_keeps_risk_axis() {
4250        // One breaching function survives its ceiling: the row is NOT fully
4251        // exempt, so the risk story stays.
4252        let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
4253        mixed.crap_above_threshold = 1;
4254        mixed.crap_exempted = 1;
4255        mixed.crap_effective_threshold = Some(30.0);
4256        assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
4257        assert_eq!(
4258            file_score_concern_axis(&mixed, CRAP_THRESHOLD),
4259            FileScoreConcern::Risk
4260        );
4261    }
4262
4263    #[test]
4264    fn file_score_concern_axis_labels_dominant_signal() {
4265        let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
4266        assert_eq!(
4267            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
4268            FileScoreConcern::Risk
4269        );
4270        assert_eq!(
4271            file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
4272            "risk"
4273        );
4274
4275        let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
4276        assert_eq!(
4277            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
4278            FileScoreConcern::Structural
4279        );
4280        assert_eq!(
4281            file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
4282            "structure"
4283        );
4284
4285        let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
4286        assert_eq!(
4287            file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
4288            FileScoreConcern::Structural
4289        );
4290    }
4291
4292    #[test]
4293    fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
4294        let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
4295        let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
4296
4297        let mut scores = [low_mi_low_risk, higher_mi_high_risk];
4298        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4299
4300        assert_eq!(
4301            scores[0].path,
4302            std::path::Path::new("/src/higher-mi-high-risk.ts")
4303        );
4304        assert_eq!(
4305            scores[1].path,
4306            std::path::Path::new("/src/low-mi-low-risk.ts")
4307        );
4308    }
4309
4310    #[test]
4311    fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
4312        let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
4313        let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
4314
4315        let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
4316        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4317
4318        assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
4319        assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
4320    }
4321
4322    #[test]
4323    fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
4324        let mut scores = [
4325            make_file_score("/src/b.ts", 70.0, 1.0),
4326            make_file_score("/src/a.ts", 70.0, 1.0),
4327            make_file_score("/src/higher-crap.ts", 70.0, 2.0),
4328            make_file_score("/src/lower-concern.ts", 80.0, 1.0),
4329        ];
4330
4331        scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4332
4333        let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
4334        assert_eq!(
4335            paths,
4336            vec![
4337                std::path::Path::new("/src/higher-crap.ts"),
4338                std::path::Path::new("/src/a.ts"),
4339                std::path::Path::new("/src/b.ts"),
4340                std::path::Path::new("/src/lower-concern.ts"),
4341            ]
4342        );
4343    }
4344
4345    #[test]
4346    fn compute_file_scores_empty_graph() {
4347        let files: Vec<crate::discover::DiscoveredFile> = vec![];
4348        let graph = build_test_graph(&files, &[], &[]);
4349        let modules: Vec<crate::source::ModuleInfo> = vec![];
4350        let file_paths = rustc_hash::FxHashMap::default();
4351
4352        let output = crate::results::DeadCodeAnalysisArtifacts {
4353            results: fallow_types::results::AnalysisResults::default(),
4354            timings: None,
4355            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4356            modules: None,
4357            files: None,
4358            script_used_packages: rustc_hash::FxHashSet::default(),
4359            trace_provenance: crate::trace::TraceProvenance::default(),
4360            file_hashes: rustc_hash::FxHashMap::default(),
4361        };
4362
4363        let result = compute_file_scores_default(
4364            &modules,
4365            &file_paths,
4366            None,
4367            output,
4368            None,
4369            std::path::Path::new("/project"),
4370        )
4371        .unwrap();
4372        assert!(result.scores.is_empty());
4373        assert!(result.circular_files.is_empty());
4374        assert!(result.top_complex_fns.is_empty());
4375        assert!(result.entry_points.is_empty());
4376        assert_eq!(result.analysis_counts.total_exports, 0);
4377        assert_eq!(result.analysis_counts.dead_files, 0);
4378    }
4379
4380    #[test]
4381    fn compute_file_scores_no_graph_returns_error() {
4382        let modules: Vec<crate::source::ModuleInfo> = vec![];
4383        let file_paths = rustc_hash::FxHashMap::default();
4384
4385        let output = crate::results::DeadCodeAnalysisArtifacts {
4386            results: fallow_types::results::AnalysisResults::default(),
4387            timings: None,
4388            graph: None,
4389            modules: None,
4390            files: None,
4391            script_used_packages: rustc_hash::FxHashSet::default(),
4392            trace_provenance: crate::trace::TraceProvenance::default(),
4393            file_hashes: rustc_hash::FxHashMap::default(),
4394        };
4395
4396        let result = compute_file_scores_default(
4397            &modules,
4398            &file_paths,
4399            None,
4400            output,
4401            None,
4402            std::path::Path::new("/project"),
4403        );
4404        assert!(result.is_err());
4405        match result {
4406            Err(msg) => assert_eq!(msg, "graph not available"),
4407            Ok(_) => panic!("expected error"),
4408        }
4409    }
4410
4411    #[test]
4412    fn compute_file_scores_single_file_with_function() {
4413        let path_a = std::path::PathBuf::from("/src/a.ts");
4414        let files = vec![crate::discover::DiscoveredFile {
4415            id: crate::discover::FileId(0),
4416            path: path_a.clone(),
4417            size_bytes: 100,
4418        }];
4419
4420        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4421            file_id: crate::discover::FileId(0),
4422            path: path_a.clone(),
4423            exports: vec![fallow_types::extract::ExportInfo {
4424                name: crate::source::ExportName::Named("foo".into()),
4425                local_name: None,
4426                is_type_only: false,
4427                visibility: crate::source::VisibilityTag::None,
4428                expected_unused_reason: None,
4429                span: oxc_span::Span::empty(0),
4430                members: vec![],
4431                is_side_effect_used: false,
4432                super_class: None,
4433                deprecated: false,
4434                deprecated_reason: None,
4435            }]
4436            .into(),
4437            ..Default::default()
4438        }];
4439
4440        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4441
4442        let modules = vec![make_module_info(
4443            0,
4444            10,
4445            vec![fallow_types::extract::FunctionComplexity {
4446                name: "foo".into(),
4447                is_private_member: false,
4448                context_named: None,
4449                line: 1,
4450                col: 0,
4451                cyclomatic: 5,
4452                cognitive: 3,
4453                line_count: 10,
4454                param_count: 0,
4455                react_hook_count: 0,
4456                react_jsx_max_depth: 0,
4457                react_prop_count: 0,
4458                source_hash: None,
4459                contributions: Vec::new(),
4460            }],
4461        )];
4462
4463        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4464            rustc_hash::FxHashMap::default();
4465        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4466
4467        let output = crate::results::DeadCodeAnalysisArtifacts {
4468            results: fallow_types::results::AnalysisResults::default(),
4469            timings: None,
4470            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4471            modules: None,
4472            files: None,
4473            script_used_packages: rustc_hash::FxHashSet::default(),
4474            trace_provenance: crate::trace::TraceProvenance::default(),
4475            file_hashes: rustc_hash::FxHashMap::default(),
4476        };
4477
4478        let result = compute_file_scores_default(
4479            &modules,
4480            &file_paths,
4481            None,
4482            output,
4483            None,
4484            std::path::Path::new("/project"),
4485        )
4486        .unwrap();
4487        assert_eq!(result.scores.len(), 1);
4488
4489        let score = &result.scores[0];
4490        assert_eq!(score.path, path_a);
4491        assert_eq!(score.total_cyclomatic, 5);
4492        assert_eq!(score.total_cognitive, 3);
4493        assert_eq!(score.function_count, 1);
4494        assert_eq!(score.lines, 10);
4495        assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
4496        assert!(score.dead_code_ratio.abs() < f64::EPSILON);
4497        assert!(result.entry_points.contains(&path_a));
4498    }
4499
4500    #[test]
4501    fn compute_file_scores_excludes_barrel_files() {
4502        let path_a = std::path::PathBuf::from("/src/index.ts");
4503        let files = vec![crate::discover::DiscoveredFile {
4504            id: crate::discover::FileId(0),
4505            path: path_a.clone(),
4506            size_bytes: 50,
4507        }];
4508
4509        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4510            file_id: crate::discover::FileId(0),
4511            path: path_a.clone(),
4512            ..Default::default()
4513        }];
4514
4515        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4516
4517        let modules = vec![make_module_info(0, 5, vec![])];
4518
4519        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4520            rustc_hash::FxHashMap::default();
4521        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4522
4523        let output = crate::results::DeadCodeAnalysisArtifacts {
4524            results: fallow_types::results::AnalysisResults::default(),
4525            timings: None,
4526            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4527            modules: None,
4528            files: None,
4529            script_used_packages: rustc_hash::FxHashSet::default(),
4530            trace_provenance: crate::trace::TraceProvenance::default(),
4531            file_hashes: rustc_hash::FxHashMap::default(),
4532        };
4533
4534        let result = compute_file_scores_default(
4535            &modules,
4536            &file_paths,
4537            None,
4538            output,
4539            None,
4540            std::path::Path::new("/project"),
4541        )
4542        .unwrap();
4543        assert!(result.scores.is_empty());
4544    }
4545
4546    #[test]
4547    fn compute_file_scores_changed_since_filter() {
4548        let path_a = std::path::PathBuf::from("/src/a.ts");
4549        let path_b = std::path::PathBuf::from("/src/b.ts");
4550        let files = vec![
4551            crate::discover::DiscoveredFile {
4552                id: crate::discover::FileId(0),
4553                path: path_a.clone(),
4554                size_bytes: 100,
4555            },
4556            crate::discover::DiscoveredFile {
4557                id: crate::discover::FileId(1),
4558                path: path_b.clone(),
4559                size_bytes: 100,
4560            },
4561        ];
4562
4563        let resolved_modules = vec![
4564            fallow_graph::resolve::ResolvedModule {
4565                file_id: crate::discover::FileId(0),
4566                path: path_a,
4567                ..Default::default()
4568            },
4569            fallow_graph::resolve::ResolvedModule {
4570                file_id: crate::discover::FileId(1),
4571                path: path_b.clone(),
4572                ..Default::default()
4573            },
4574        ];
4575
4576        let graph = build_test_graph(&files, &[], &resolved_modules);
4577
4578        let modules = vec![
4579            make_module_info(
4580                0,
4581                10,
4582                vec![fallow_types::extract::FunctionComplexity {
4583                    name: "fn_a".into(),
4584                    is_private_member: false,
4585                    context_named: None,
4586                    line: 1,
4587                    col: 0,
4588                    cyclomatic: 2,
4589                    cognitive: 1,
4590                    line_count: 10,
4591                    param_count: 0,
4592                    react_hook_count: 0,
4593                    react_jsx_max_depth: 0,
4594                    react_prop_count: 0,
4595                    source_hash: None,
4596                    contributions: Vec::new(),
4597                }],
4598            ),
4599            make_module_info(
4600                1,
4601                10,
4602                vec![fallow_types::extract::FunctionComplexity {
4603                    name: "fn_b".into(),
4604                    is_private_member: false,
4605                    context_named: None,
4606                    line: 1,
4607                    col: 0,
4608                    cyclomatic: 3,
4609                    cognitive: 2,
4610                    line_count: 10,
4611                    param_count: 0,
4612                    react_hook_count: 0,
4613                    react_jsx_max_depth: 0,
4614                    react_prop_count: 0,
4615                    source_hash: None,
4616                    contributions: Vec::new(),
4617                }],
4618            ),
4619        ];
4620
4621        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4622            rustc_hash::FxHashMap::default();
4623        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4624        file_paths.insert(crate::discover::FileId(1), &files[1].path);
4625
4626        let path_b_check = std::path::PathBuf::from("/src/b.ts");
4627        let mut changed = rustc_hash::FxHashSet::default();
4628        changed.insert(path_b);
4629
4630        let output = crate::results::DeadCodeAnalysisArtifacts {
4631            results: fallow_types::results::AnalysisResults::default(),
4632            timings: None,
4633            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4634            modules: None,
4635            files: None,
4636            script_used_packages: rustc_hash::FxHashSet::default(),
4637            trace_provenance: crate::trace::TraceProvenance::default(),
4638            file_hashes: rustc_hash::FxHashMap::default(),
4639        };
4640
4641        let result = compute_file_scores_default(
4642            &modules,
4643            &file_paths,
4644            Some(&changed),
4645            output,
4646            None,
4647            std::path::Path::new("/project"),
4648        )
4649        .unwrap();
4650        assert_eq!(result.scores.len(), 1);
4651        assert_eq!(result.scores[0].path, path_b_check);
4652    }
4653
4654    #[test]
4655    fn compute_file_scores_sorted_by_triage_concern() {
4656        let path_a = std::path::PathBuf::from("/src/a.ts");
4657        let path_b = std::path::PathBuf::from("/src/b.ts");
4658        let files = vec![
4659            crate::discover::DiscoveredFile {
4660                id: crate::discover::FileId(0),
4661                path: path_a.clone(),
4662                size_bytes: 100,
4663            },
4664            crate::discover::DiscoveredFile {
4665                id: crate::discover::FileId(1),
4666                path: path_b.clone(),
4667                size_bytes: 100,
4668            },
4669        ];
4670
4671        let resolved_modules = vec![
4672            fallow_graph::resolve::ResolvedModule {
4673                file_id: crate::discover::FileId(0),
4674                path: path_a.clone(),
4675                ..Default::default()
4676            },
4677            fallow_graph::resolve::ResolvedModule {
4678                file_id: crate::discover::FileId(1),
4679                path: path_b,
4680                ..Default::default()
4681            },
4682        ];
4683
4684        let graph = build_test_graph(&files, &[], &resolved_modules);
4685
4686        let modules = vec![
4687            make_module_info(
4688                0,
4689                10,
4690                vec![fallow_types::extract::FunctionComplexity {
4691                    name: "complex_fn".into(),
4692                    is_private_member: false,
4693                    context_named: None,
4694                    line: 1,
4695                    col: 0,
4696                    cyclomatic: 30,
4697                    cognitive: 20,
4698                    line_count: 10,
4699                    param_count: 0,
4700                    react_hook_count: 0,
4701                    react_jsx_max_depth: 0,
4702                    react_prop_count: 0,
4703                    source_hash: None,
4704                    contributions: Vec::new(),
4705                }],
4706            ),
4707            make_module_info(
4708                1,
4709                100,
4710                vec![fallow_types::extract::FunctionComplexity {
4711                    name: "simple_fn".into(),
4712                    is_private_member: false,
4713                    context_named: None,
4714                    line: 1,
4715                    col: 0,
4716                    cyclomatic: 1,
4717                    cognitive: 0,
4718                    line_count: 100,
4719                    param_count: 0,
4720                    react_hook_count: 0,
4721                    react_jsx_max_depth: 0,
4722                    react_prop_count: 0,
4723                    source_hash: None,
4724                    contributions: Vec::new(),
4725                }],
4726            ),
4727        ];
4728
4729        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4730            rustc_hash::FxHashMap::default();
4731        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4732        file_paths.insert(crate::discover::FileId(1), &files[1].path);
4733
4734        let output = crate::results::DeadCodeAnalysisArtifacts {
4735            results: fallow_types::results::AnalysisResults::default(),
4736            timings: None,
4737            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4738            modules: None,
4739            files: None,
4740            script_used_packages: rustc_hash::FxHashSet::default(),
4741            trace_provenance: crate::trace::TraceProvenance::default(),
4742            file_hashes: rustc_hash::FxHashMap::default(),
4743        };
4744
4745        let result = compute_file_scores_default(
4746            &modules,
4747            &file_paths,
4748            None,
4749            output,
4750            None,
4751            std::path::Path::new("/project"),
4752        )
4753        .unwrap();
4754        assert_eq!(result.scores.len(), 2);
4755        assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
4756        assert_eq!(result.scores[0].path, path_a);
4757    }
4758
4759    #[test]
4760    fn compute_file_scores_with_unused_file_populates_evidence() {
4761        let path_a = std::path::PathBuf::from("/src/unused.ts");
4762        let files = vec![crate::discover::DiscoveredFile {
4763            id: crate::discover::FileId(0),
4764            path: path_a.clone(),
4765            size_bytes: 100,
4766        }];
4767
4768        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4769            file_id: crate::discover::FileId(0),
4770            path: path_a.clone(),
4771            exports: vec![fallow_types::extract::ExportInfo {
4772                name: crate::source::ExportName::Named("orphan".into()),
4773                local_name: None,
4774                is_type_only: false,
4775                visibility: crate::source::VisibilityTag::None,
4776                expected_unused_reason: None,
4777                span: oxc_span::Span::empty(0),
4778                members: vec![],
4779                is_side_effect_used: false,
4780                super_class: None,
4781                deprecated: false,
4782                deprecated_reason: None,
4783            }]
4784            .into(),
4785            ..Default::default()
4786        }];
4787
4788        let graph = build_test_graph(&files, &[], &resolved_modules);
4789
4790        let modules = vec![make_module_info(
4791            0,
4792            10,
4793            vec![fallow_types::extract::FunctionComplexity {
4794                name: "orphan".into(),
4795                is_private_member: false,
4796                context_named: None,
4797                line: 1,
4798                col: 0,
4799                cyclomatic: 1,
4800                cognitive: 0,
4801                line_count: 10,
4802                param_count: 0,
4803                react_hook_count: 0,
4804                react_jsx_max_depth: 0,
4805                react_prop_count: 0,
4806                source_hash: None,
4807                contributions: Vec::new(),
4808            }],
4809        )];
4810
4811        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4812            rustc_hash::FxHashMap::default();
4813        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4814
4815        let mut results = fallow_types::results::AnalysisResults::default();
4816        results.unused_files.push(
4817            fallow_types::output_dead_code::UnusedFileFinding::with_actions(
4818                fallow_types::results::UnusedFile {
4819                    path: path_a.clone(),
4820                },
4821            ),
4822        );
4823
4824        let output = crate::results::DeadCodeAnalysisArtifacts {
4825            results,
4826            timings: None,
4827            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4828            modules: None,
4829            files: None,
4830            script_used_packages: rustc_hash::FxHashSet::default(),
4831            trace_provenance: crate::trace::TraceProvenance::default(),
4832            file_hashes: rustc_hash::FxHashMap::default(),
4833        };
4834
4835        let result = compute_file_scores_default(
4836            &modules,
4837            &file_paths,
4838            None,
4839            output,
4840            None,
4841            std::path::Path::new("/project"),
4842        )
4843        .unwrap();
4844        assert_eq!(result.scores.len(), 1);
4845        assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
4846        assert!(result.unused_export_names.contains_key(&path_a));
4847        let names = &result.unused_export_names[&path_a];
4848        assert_eq!(names, &["orphan"]);
4849        assert_eq!(result.analysis_counts.dead_files, 1);
4850    }
4851
4852    #[test]
4853    #[expect(
4854        clippy::too_many_lines,
4855        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4856    )]
4857    fn compute_file_scores_tracks_top_complex_functions() {
4858        let path_a = std::path::PathBuf::from("/src/complex.ts");
4859        let files = vec![crate::discover::DiscoveredFile {
4860            id: crate::discover::FileId(0),
4861            path: path_a.clone(),
4862            size_bytes: 500,
4863        }];
4864
4865        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4866            file_id: crate::discover::FileId(0),
4867            path: path_a.clone(),
4868            ..Default::default()
4869        }];
4870
4871        let graph = build_test_graph(&files, &[], &resolved_modules);
4872
4873        let modules = vec![make_module_info(
4874            0,
4875            50,
4876            vec![
4877                fallow_types::extract::FunctionComplexity {
4878                    name: "high".into(),
4879                    is_private_member: false,
4880                    context_named: None,
4881                    line: 1,
4882                    col: 0,
4883                    cyclomatic: 10,
4884                    cognitive: 20,
4885                    line_count: 10,
4886                    param_count: 0,
4887                    react_hook_count: 0,
4888                    react_jsx_max_depth: 0,
4889                    react_prop_count: 0,
4890                    source_hash: None,
4891                    contributions: Vec::new(),
4892                },
4893                fallow_types::extract::FunctionComplexity {
4894                    name: "medium".into(),
4895                    is_private_member: false,
4896                    context_named: None,
4897                    line: 11,
4898                    col: 0,
4899                    cyclomatic: 5,
4900                    cognitive: 10,
4901                    line_count: 10,
4902                    param_count: 0,
4903                    react_hook_count: 0,
4904                    react_jsx_max_depth: 0,
4905                    react_prop_count: 0,
4906                    source_hash: None,
4907                    contributions: Vec::new(),
4908                },
4909                fallow_types::extract::FunctionComplexity {
4910                    name: "low".into(),
4911                    is_private_member: false,
4912                    context_named: None,
4913                    line: 21,
4914                    col: 0,
4915                    cyclomatic: 2,
4916                    cognitive: 5,
4917                    line_count: 10,
4918                    param_count: 0,
4919                    react_hook_count: 0,
4920                    react_jsx_max_depth: 0,
4921                    react_prop_count: 0,
4922                    source_hash: None,
4923                    contributions: Vec::new(),
4924                },
4925                fallow_types::extract::FunctionComplexity {
4926                    name: "trivial".into(),
4927                    is_private_member: false,
4928                    context_named: None,
4929                    line: 31,
4930                    col: 0,
4931                    cyclomatic: 1,
4932                    cognitive: 1,
4933                    line_count: 10,
4934                    param_count: 0,
4935                    react_hook_count: 0,
4936                    react_jsx_max_depth: 0,
4937                    react_prop_count: 0,
4938                    source_hash: None,
4939                    contributions: Vec::new(),
4940                },
4941            ],
4942        )];
4943
4944        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4945            rustc_hash::FxHashMap::default();
4946        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4947
4948        let output = crate::results::DeadCodeAnalysisArtifacts {
4949            results: fallow_types::results::AnalysisResults::default(),
4950            timings: None,
4951            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4952            modules: None,
4953            files: None,
4954            script_used_packages: rustc_hash::FxHashSet::default(),
4955            trace_provenance: crate::trace::TraceProvenance::default(),
4956            file_hashes: rustc_hash::FxHashMap::default(),
4957        };
4958
4959        let result = compute_file_scores_default(
4960            &modules,
4961            &file_paths,
4962            None,
4963            output,
4964            None,
4965            std::path::Path::new("/project"),
4966        )
4967        .unwrap();
4968        assert!(result.top_complex_fns.contains_key(&path_a));
4969        let top = &result.top_complex_fns[&path_a];
4970        assert_eq!(top.len(), 3);
4971        assert_eq!(top[0].0, "high");
4972        assert_eq!(top[0].2, 20);
4973        assert_eq!(top[1].0, "medium");
4974        assert_eq!(top[1].2, 10);
4975        assert_eq!(top[2].0, "low");
4976        assert_eq!(top[2].2, 5);
4977    }
4978
4979    #[test]
4980    #[expect(
4981        clippy::too_many_lines,
4982        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4983    )]
4984    fn compute_file_scores_with_circular_deps() {
4985        let path_a = std::path::PathBuf::from("/src/a.ts");
4986        let path_b = std::path::PathBuf::from("/src/b.ts");
4987        let files = vec![
4988            crate::discover::DiscoveredFile {
4989                id: crate::discover::FileId(0),
4990                path: path_a.clone(),
4991                size_bytes: 100,
4992            },
4993            crate::discover::DiscoveredFile {
4994                id: crate::discover::FileId(1),
4995                path: path_b.clone(),
4996                size_bytes: 100,
4997            },
4998        ];
4999
5000        let resolved_modules = vec![
5001            fallow_graph::resolve::ResolvedModule {
5002                file_id: crate::discover::FileId(0),
5003                path: path_a.clone(),
5004                ..Default::default()
5005            },
5006            fallow_graph::resolve::ResolvedModule {
5007                file_id: crate::discover::FileId(1),
5008                path: path_b.clone(),
5009                ..Default::default()
5010            },
5011        ];
5012
5013        let graph = build_test_graph(&files, &[], &resolved_modules);
5014
5015        let modules = vec![
5016            make_module_info(
5017                0,
5018                10,
5019                vec![fallow_types::extract::FunctionComplexity {
5020                    name: "fn_a".into(),
5021                    is_private_member: false,
5022                    context_named: None,
5023                    line: 1,
5024                    col: 0,
5025                    cyclomatic: 2,
5026                    cognitive: 1,
5027                    line_count: 10,
5028                    param_count: 0,
5029                    react_hook_count: 0,
5030                    react_jsx_max_depth: 0,
5031                    react_prop_count: 0,
5032                    source_hash: None,
5033                    contributions: Vec::new(),
5034                }],
5035            ),
5036            make_module_info(
5037                1,
5038                10,
5039                vec![fallow_types::extract::FunctionComplexity {
5040                    name: "fn_b".into(),
5041                    is_private_member: false,
5042                    context_named: None,
5043                    line: 1,
5044                    col: 0,
5045                    cyclomatic: 3,
5046                    cognitive: 2,
5047                    line_count: 10,
5048                    param_count: 0,
5049                    react_hook_count: 0,
5050                    react_jsx_max_depth: 0,
5051                    react_prop_count: 0,
5052                    source_hash: None,
5053                    contributions: Vec::new(),
5054                }],
5055            ),
5056        ];
5057
5058        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5059            rustc_hash::FxHashMap::default();
5060        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5061        file_paths.insert(crate::discover::FileId(1), &files[1].path);
5062
5063        let mut results = fallow_types::results::AnalysisResults::default();
5064        results.circular_dependencies.push(
5065            fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
5066                fallow_types::results::CircularDependency {
5067                    files: vec![path_a.clone(), path_b.clone()],
5068                    length: 2,
5069                    line: 1,
5070                    col: 0,
5071                    edges: Vec::new(),
5072                    is_cross_package: false,
5073                },
5074            ),
5075        );
5076
5077        let output = crate::results::DeadCodeAnalysisArtifacts {
5078            results,
5079            timings: None,
5080            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5081            modules: None,
5082            files: None,
5083            script_used_packages: rustc_hash::FxHashSet::default(),
5084            trace_provenance: crate::trace::TraceProvenance::default(),
5085            file_hashes: rustc_hash::FxHashMap::default(),
5086        };
5087
5088        let result = compute_file_scores_default(
5089            &modules,
5090            &file_paths,
5091            None,
5092            output,
5093            None,
5094            std::path::Path::new("/project"),
5095        )
5096        .unwrap();
5097        assert!(result.circular_files.contains(&path_a));
5098        assert!(result.circular_files.contains(&path_b));
5099        assert!(result.cycle_members.contains_key(&path_a));
5100        assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
5101        assert!(result.cycle_members.contains_key(&path_b));
5102        assert_eq!(result.cycle_members[&path_b], vec![path_a]);
5103        assert_eq!(result.analysis_counts.circular_deps, 1);
5104    }
5105
5106    #[test]
5107    #[expect(
5108        clippy::too_many_lines,
5109        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5110    )]
5111    fn compute_file_scores_analysis_counts_unused_exports_and_types() {
5112        let path_a = std::path::PathBuf::from("/src/a.ts");
5113        let files = vec![crate::discover::DiscoveredFile {
5114            id: crate::discover::FileId(0),
5115            path: path_a.clone(),
5116            size_bytes: 100,
5117        }];
5118
5119        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5120            file_id: crate::discover::FileId(0),
5121            path: path_a.clone(),
5122            exports: vec![
5123                fallow_types::extract::ExportInfo {
5124                    name: crate::source::ExportName::Named("foo".into()),
5125                    local_name: None,
5126                    is_type_only: false,
5127                    visibility: crate::source::VisibilityTag::None,
5128                    expected_unused_reason: None,
5129                    span: oxc_span::Span::empty(0),
5130                    members: vec![],
5131                    is_side_effect_used: false,
5132                    super_class: None,
5133                    deprecated: false,
5134                    deprecated_reason: None,
5135                },
5136                fallow_types::extract::ExportInfo {
5137                    name: crate::source::ExportName::Named("bar".into()),
5138                    local_name: None,
5139                    is_type_only: false,
5140                    visibility: crate::source::VisibilityTag::None,
5141                    expected_unused_reason: None,
5142                    span: oxc_span::Span::empty(0),
5143                    members: vec![],
5144                    is_side_effect_used: false,
5145                    super_class: None,
5146                    deprecated: false,
5147                    deprecated_reason: None,
5148                },
5149            ]
5150            .into(),
5151            ..Default::default()
5152        }];
5153
5154        let graph = build_test_graph(&files, &[], &resolved_modules);
5155
5156        let mut module = make_module_info(
5157            0,
5158            10,
5159            vec![fallow_types::extract::FunctionComplexity {
5160                name: "fn_a".into(),
5161                is_private_member: false,
5162                context_named: None,
5163                line: 1,
5164                col: 0,
5165                cyclomatic: 1,
5166                cognitive: 0,
5167                line_count: 10,
5168                param_count: 0,
5169                react_hook_count: 0,
5170                react_jsx_max_depth: 0,
5171                react_prop_count: 0,
5172                source_hash: None,
5173                contributions: Vec::new(),
5174            }],
5175        );
5176        module.exports = vec![
5177            fallow_types::extract::ExportInfo {
5178                name: crate::source::ExportName::Named("foo".into()),
5179                local_name: None,
5180                is_type_only: false,
5181                visibility: crate::source::VisibilityTag::None,
5182                expected_unused_reason: None,
5183                span: oxc_span::Span::empty(0),
5184                members: vec![],
5185                is_side_effect_used: false,
5186                super_class: None,
5187                deprecated: false,
5188                deprecated_reason: None,
5189            },
5190            fallow_types::extract::ExportInfo {
5191                name: crate::source::ExportName::Named("bar".into()),
5192                local_name: None,
5193                is_type_only: false,
5194                visibility: crate::source::VisibilityTag::None,
5195                expected_unused_reason: None,
5196                span: oxc_span::Span::empty(0),
5197                members: vec![],
5198                is_side_effect_used: false,
5199                super_class: None,
5200                deprecated: false,
5201                deprecated_reason: None,
5202            },
5203        ]
5204        .into();
5205        let modules = vec![module];
5206
5207        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5208            rustc_hash::FxHashMap::default();
5209        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5210
5211        let mut results = fallow_types::results::AnalysisResults::default();
5212        results.unused_exports.push(
5213            fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5214                fallow_types::results::UnusedExport {
5215                    path: path_a.clone(),
5216                    export_name: "foo".into(),
5217                    is_type_only: false,
5218                    line: 1,
5219                    col: 0,
5220                    span_start: 0,
5221                    is_re_export: false,
5222                    deprecated: false,
5223                    deprecated_reason: None,
5224                },
5225            ),
5226        );
5227        results.unused_types.push(
5228            fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
5229                fallow_types::results::UnusedExport {
5230                    path: path_a,
5231                    export_name: "MyType".into(),
5232                    is_type_only: true,
5233                    line: 5,
5234                    col: 0,
5235                    span_start: 40,
5236                    is_re_export: false,
5237                    deprecated: false,
5238                    deprecated_reason: None,
5239                },
5240            ),
5241        );
5242        results.unused_dependencies.push(
5243            fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
5244                fallow_types::results::UnusedDependency {
5245                    package_name: "lodash".into(),
5246                    location: fallow_types::results::DependencyLocation::Dependencies,
5247                    path: std::path::PathBuf::from("/package.json"),
5248                    line: 1,
5249                    used_in_workspaces: Vec::new(),
5250                    declared_and_imported_in: Vec::new(),
5251                },
5252            ),
5253        );
5254
5255        let output = crate::results::DeadCodeAnalysisArtifacts {
5256            results,
5257            timings: None,
5258            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5259            modules: None,
5260            files: None,
5261            script_used_packages: rustc_hash::FxHashSet::default(),
5262            trace_provenance: crate::trace::TraceProvenance::default(),
5263            file_hashes: rustc_hash::FxHashMap::default(),
5264        };
5265
5266        let result = compute_file_scores_default(
5267            &modules,
5268            &file_paths,
5269            None,
5270            output,
5271            None,
5272            std::path::Path::new("/project"),
5273        )
5274        .unwrap();
5275        assert_eq!(result.analysis_counts.total_exports, 2);
5276        assert_eq!(result.analysis_counts.dead_exports, 2);
5277        assert_eq!(result.analysis_counts.unused_deps, 1);
5278    }
5279
5280    /// Regression: total_exports must count graph modules, not extraction modules.
5281    #[test]
5282    #[expect(
5283        clippy::too_many_lines,
5284        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5285    )]
5286    fn total_exports_counts_graph_modules_not_extraction_modules() {
5287        let path_a = std::path::PathBuf::from("/src/a.ts");
5288        let files = vec![crate::discover::DiscoveredFile {
5289            id: crate::discover::FileId(0),
5290            path: path_a.clone(),
5291            size_bytes: 100,
5292        }];
5293
5294        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5295            file_id: crate::discover::FileId(0),
5296            path: path_a.clone(),
5297            exports: vec![
5298                fallow_types::extract::ExportInfo {
5299                    name: crate::source::ExportName::Named("foo".into()),
5300                    local_name: None,
5301                    is_type_only: false,
5302                    visibility: crate::source::VisibilityTag::None,
5303                    expected_unused_reason: None,
5304                    span: oxc_span::Span::empty(0),
5305                    members: vec![],
5306                    is_side_effect_used: false,
5307                    super_class: None,
5308                    deprecated: false,
5309                    deprecated_reason: None,
5310                },
5311                fallow_types::extract::ExportInfo {
5312                    name: crate::source::ExportName::Named("bar".into()),
5313                    local_name: None,
5314                    is_type_only: false,
5315                    visibility: crate::source::VisibilityTag::None,
5316                    expected_unused_reason: None,
5317                    span: oxc_span::Span::empty(0),
5318                    members: vec![],
5319                    is_side_effect_used: false,
5320                    super_class: None,
5321                    deprecated: false,
5322                    deprecated_reason: None,
5323                },
5324                fallow_types::extract::ExportInfo {
5325                    name: crate::source::ExportName::Named("baz".into()),
5326                    local_name: None,
5327                    is_type_only: false,
5328                    visibility: crate::source::VisibilityTag::None,
5329                    expected_unused_reason: None,
5330                    span: oxc_span::Span::new(0, 0),
5331                    members: vec![],
5332                    is_side_effect_used: false,
5333                    super_class: None,
5334                    deprecated: false,
5335                    deprecated_reason: None,
5336                },
5337            ]
5338            .into(),
5339            ..Default::default()
5340        }];
5341
5342        let graph = build_test_graph(&files, &[], &resolved_modules);
5343
5344        let mut module = make_module_info(
5345            0,
5346            10,
5347            vec![fallow_types::extract::FunctionComplexity {
5348                name: "fn_a".into(),
5349                is_private_member: false,
5350                context_named: None,
5351                line: 1,
5352                col: 0,
5353                cyclomatic: 1,
5354                cognitive: 0,
5355                line_count: 10,
5356                param_count: 0,
5357                react_hook_count: 0,
5358                react_jsx_max_depth: 0,
5359                react_prop_count: 0,
5360                source_hash: None,
5361                contributions: Vec::new(),
5362            }],
5363        );
5364        module.exports = vec![
5365            fallow_types::extract::ExportInfo {
5366                name: crate::source::ExportName::Named("foo".into()),
5367                local_name: None,
5368                is_type_only: false,
5369                visibility: crate::source::VisibilityTag::None,
5370                expected_unused_reason: None,
5371                span: oxc_span::Span::empty(0),
5372                members: vec![],
5373                is_side_effect_used: false,
5374                super_class: None,
5375                deprecated: false,
5376                deprecated_reason: None,
5377            },
5378            fallow_types::extract::ExportInfo {
5379                name: crate::source::ExportName::Named("bar".into()),
5380                local_name: None,
5381                is_type_only: false,
5382                visibility: crate::source::VisibilityTag::None,
5383                expected_unused_reason: None,
5384                span: oxc_span::Span::empty(0),
5385                members: vec![],
5386                is_side_effect_used: false,
5387                super_class: None,
5388                deprecated: false,
5389                deprecated_reason: None,
5390            },
5391        ]
5392        .into();
5393        let modules = vec![module];
5394
5395        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5396            rustc_hash::FxHashMap::default();
5397        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5398
5399        let mut results = fallow_types::results::AnalysisResults::default();
5400        for name in ["foo", "bar", "baz"] {
5401            results.unused_exports.push(
5402                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5403                    fallow_types::results::UnusedExport {
5404                        path: path_a.clone(),
5405                        export_name: name.into(),
5406                        is_type_only: false,
5407                        line: 1,
5408                        col: 0,
5409                        span_start: 0,
5410                        is_re_export: name == "baz",
5411                        deprecated: false,
5412                        deprecated_reason: None,
5413                    },
5414                ),
5415            );
5416        }
5417
5418        let output = crate::results::DeadCodeAnalysisArtifacts {
5419            results,
5420            timings: None,
5421            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5422            modules: None,
5423            files: None,
5424            script_used_packages: rustc_hash::FxHashSet::default(),
5425            trace_provenance: crate::trace::TraceProvenance::default(),
5426            file_hashes: rustc_hash::FxHashMap::default(),
5427        };
5428
5429        let result = compute_file_scores_default(
5430            &modules,
5431            &file_paths,
5432            None,
5433            output,
5434            None,
5435            std::path::Path::new("/project"),
5436        )
5437        .unwrap();
5438        assert_eq!(result.analysis_counts.total_exports, 3);
5439        assert_eq!(result.analysis_counts.dead_exports, 3);
5440    }
5441
5442    #[test]
5443    fn compute_file_scores_module_not_in_file_paths_skipped() {
5444        let path_a = std::path::PathBuf::from("/src/a.ts");
5445        let files = vec![crate::discover::DiscoveredFile {
5446            id: crate::discover::FileId(0),
5447            path: path_a.clone(),
5448            size_bytes: 100,
5449        }];
5450
5451        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5452            file_id: crate::discover::FileId(0),
5453            path: path_a,
5454            ..Default::default()
5455        }];
5456
5457        let graph = build_test_graph(&files, &[], &resolved_modules);
5458
5459        let modules = vec![make_module_info(
5460            0,
5461            10,
5462            vec![fallow_types::extract::FunctionComplexity {
5463                name: "fn_a".into(),
5464                is_private_member: false,
5465                context_named: None,
5466                line: 1,
5467                col: 0,
5468                cyclomatic: 2,
5469                cognitive: 1,
5470                line_count: 10,
5471                param_count: 0,
5472                react_hook_count: 0,
5473                react_jsx_max_depth: 0,
5474                react_prop_count: 0,
5475                source_hash: None,
5476                contributions: Vec::new(),
5477            }],
5478        )];
5479
5480        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5481            rustc_hash::FxHashMap::default();
5482
5483        let output = crate::results::DeadCodeAnalysisArtifacts {
5484            results: fallow_types::results::AnalysisResults::default(),
5485            timings: None,
5486            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5487            modules: None,
5488            files: None,
5489            script_used_packages: rustc_hash::FxHashSet::default(),
5490            trace_provenance: crate::trace::TraceProvenance::default(),
5491            file_hashes: rustc_hash::FxHashMap::default(),
5492        };
5493
5494        let result = compute_file_scores_default(
5495            &modules,
5496            &file_paths,
5497            None,
5498            output,
5499            None,
5500            std::path::Path::new("/project"),
5501        )
5502        .unwrap();
5503        assert!(result.scores.is_empty());
5504    }
5505
5506    #[test]
5507    fn compute_file_scores_mi_rounded_to_one_decimal() {
5508        let path_a = std::path::PathBuf::from("/src/a.ts");
5509        let files = vec![crate::discover::DiscoveredFile {
5510            id: crate::discover::FileId(0),
5511            path: path_a.clone(),
5512            size_bytes: 100,
5513        }];
5514
5515        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5516            file_id: crate::discover::FileId(0),
5517            path: path_a.clone(),
5518            ..Default::default()
5519        }];
5520
5521        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5522
5523        let modules = vec![make_module_info(
5524            0,
5525            100,
5526            vec![fallow_types::extract::FunctionComplexity {
5527                name: "fn".into(),
5528                is_private_member: false,
5529                context_named: None,
5530                line: 1,
5531                col: 0,
5532                cyclomatic: 7,
5533                cognitive: 3,
5534                line_count: 100,
5535                param_count: 0,
5536                react_hook_count: 0,
5537                react_jsx_max_depth: 0,
5538                react_prop_count: 0,
5539                source_hash: None,
5540                contributions: Vec::new(),
5541            }],
5542        )];
5543
5544        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5545            rustc_hash::FxHashMap::default();
5546        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5547
5548        let output = crate::results::DeadCodeAnalysisArtifacts {
5549            results: fallow_types::results::AnalysisResults::default(),
5550            timings: None,
5551            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5552            modules: None,
5553            files: None,
5554            script_used_packages: rustc_hash::FxHashSet::default(),
5555            trace_provenance: crate::trace::TraceProvenance::default(),
5556            file_hashes: rustc_hash::FxHashMap::default(),
5557        };
5558
5559        let result = compute_file_scores_default(
5560            &modules,
5561            &file_paths,
5562            None,
5563            output,
5564            None,
5565            std::path::Path::new("/project"),
5566        )
5567        .unwrap();
5568        let mi = result.scores[0].maintainability_index;
5569        let rounded = (mi * 10.0).round() / 10.0;
5570        assert!((mi - rounded).abs() < f64::EPSILON);
5571    }
5572
5573    #[test]
5574    fn compute_file_scores_value_export_counts_tracked() {
5575        let path_a = std::path::PathBuf::from("/src/a.ts");
5576        let files = vec![crate::discover::DiscoveredFile {
5577            id: crate::discover::FileId(0),
5578            path: path_a.clone(),
5579            size_bytes: 100,
5580        }];
5581
5582        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5583            file_id: crate::discover::FileId(0),
5584            path: path_a.clone(),
5585            exports: vec![
5586                fallow_types::extract::ExportInfo {
5587                    name: crate::source::ExportName::Named("a".into()),
5588                    local_name: None,
5589                    is_type_only: false,
5590                    visibility: crate::source::VisibilityTag::None,
5591                    expected_unused_reason: None,
5592                    span: oxc_span::Span::empty(0),
5593                    members: vec![],
5594                    is_side_effect_used: false,
5595                    super_class: None,
5596                    deprecated: false,
5597                    deprecated_reason: None,
5598                },
5599                fallow_types::extract::ExportInfo {
5600                    name: crate::source::ExportName::Named("b".into()),
5601                    local_name: None,
5602                    is_type_only: false,
5603                    visibility: crate::source::VisibilityTag::None,
5604                    expected_unused_reason: None,
5605                    span: oxc_span::Span::empty(0),
5606                    members: vec![],
5607                    is_side_effect_used: false,
5608                    super_class: None,
5609                    deprecated: false,
5610                    deprecated_reason: None,
5611                },
5612                fallow_types::extract::ExportInfo {
5613                    name: crate::source::ExportName::Named("T".into()),
5614                    local_name: None,
5615                    is_type_only: true,
5616                    visibility: crate::source::VisibilityTag::None,
5617                    expected_unused_reason: None,
5618                    span: oxc_span::Span::empty(0),
5619                    members: vec![],
5620                    is_side_effect_used: false,
5621                    super_class: None,
5622                    deprecated: false,
5623                    deprecated_reason: None,
5624                },
5625            ]
5626            .into(),
5627            ..Default::default()
5628        }];
5629
5630        let graph = build_test_graph(&files, &[], &resolved_modules);
5631
5632        let modules = vec![make_module_info(
5633            0,
5634            10,
5635            vec![fallow_types::extract::FunctionComplexity {
5636                name: "fn_a".into(),
5637                is_private_member: false,
5638                context_named: None,
5639                line: 1,
5640                col: 0,
5641                cyclomatic: 2,
5642                cognitive: 1,
5643                line_count: 10,
5644                param_count: 0,
5645                react_hook_count: 0,
5646                react_jsx_max_depth: 0,
5647                react_prop_count: 0,
5648                source_hash: None,
5649                contributions: Vec::new(),
5650            }],
5651        )];
5652
5653        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5654            rustc_hash::FxHashMap::default();
5655        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5656
5657        let output = crate::results::DeadCodeAnalysisArtifacts {
5658            results: fallow_types::results::AnalysisResults::default(),
5659            timings: None,
5660            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5661            modules: None,
5662            files: None,
5663            script_used_packages: rustc_hash::FxHashSet::default(),
5664            trace_provenance: crate::trace::TraceProvenance::default(),
5665            file_hashes: rustc_hash::FxHashMap::default(),
5666        };
5667
5668        let result = compute_file_scores_default(
5669            &modules,
5670            &file_paths,
5671            None,
5672            output,
5673            None,
5674            std::path::Path::new("/project"),
5675        )
5676        .unwrap();
5677        assert_eq!(result.value_export_counts[&path_a], 2);
5678    }
5679
5680    #[test]
5681    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
5682        let path_a = std::path::PathBuf::from("/src/simple.ts");
5683        let files = vec![crate::discover::DiscoveredFile {
5684            id: crate::discover::FileId(0),
5685            path: path_a.clone(),
5686            size_bytes: 100,
5687        }];
5688
5689        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5690            file_id: crate::discover::FileId(0),
5691            path: path_a.clone(),
5692            ..Default::default()
5693        }];
5694
5695        let graph = build_test_graph(&files, &[], &resolved_modules);
5696
5697        let modules = vec![make_module_info(
5698            0,
5699            10,
5700            vec![fallow_types::extract::FunctionComplexity {
5701                name: "trivial".into(),
5702                is_private_member: false,
5703                context_named: None,
5704                line: 1,
5705                col: 0,
5706                cyclomatic: 1,
5707                cognitive: 0,
5708                line_count: 10,
5709                param_count: 0,
5710                react_hook_count: 0,
5711                react_jsx_max_depth: 0,
5712                react_prop_count: 0,
5713                source_hash: None,
5714                contributions: Vec::new(),
5715            }],
5716        )];
5717
5718        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5719            rustc_hash::FxHashMap::default();
5720        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5721
5722        let output = crate::results::DeadCodeAnalysisArtifacts {
5723            results: fallow_types::results::AnalysisResults::default(),
5724            timings: None,
5725            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5726            modules: None,
5727            files: None,
5728            script_used_packages: rustc_hash::FxHashSet::default(),
5729            trace_provenance: crate::trace::TraceProvenance::default(),
5730            file_hashes: rustc_hash::FxHashMap::default(),
5731        };
5732
5733        let result = compute_file_scores_default(
5734            &modules,
5735            &file_paths,
5736            None,
5737            output,
5738            None,
5739            std::path::Path::new("/project"),
5740        )
5741        .unwrap();
5742        assert!(!result.top_complex_fns.contains_key(&path_a));
5743    }
5744
5745    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
5746        fallow_types::extract::FunctionComplexity {
5747            name: "test_fn".into(),
5748            is_private_member: false,
5749            context_named: None,
5750            line: 1,
5751            col: 0,
5752            cyclomatic,
5753            cognitive: 0,
5754            line_count: 10,
5755            param_count: 0,
5756            react_hook_count: 0,
5757            react_jsx_max_depth: 0,
5758            react_prop_count: 0,
5759            source_hash: None,
5760            contributions: Vec::new(),
5761        }
5762    }
5763
5764    fn make_named_fn_complexity(
5765        name: &str,
5766        line: u32,
5767        cyclomatic: u16,
5768    ) -> fallow_types::extract::FunctionComplexity {
5769        fallow_types::extract::FunctionComplexity {
5770            name: name.into(),
5771            is_private_member: false,
5772            context_named: None,
5773            line,
5774            col: 0,
5775            cyclomatic,
5776            cognitive: 0,
5777            line_count: 10,
5778            param_count: 0,
5779            react_hook_count: 0,
5780            react_jsx_max_depth: 0,
5781            react_prop_count: 0,
5782            source_hash: None,
5783            contributions: Vec::new(),
5784        }
5785    }
5786
5787    fn crap_override_entry(
5788        files: &[&str],
5789        functions: &[&str],
5790        max_crap: Option<f64>,
5791    ) -> fallow_config::HealthThresholdOverride {
5792        fallow_config::HealthThresholdOverride {
5793            files: files.iter().map(ToString::to_string).collect(),
5794            functions: functions.iter().map(ToString::to_string).collect(),
5795            max_cyclomatic: None,
5796            max_cognitive: None,
5797            max_crap,
5798            max_unit_size: None,
5799            reason: Some("test override".into()),
5800        }
5801    }
5802
5803    fn estimated_signals_with(
5804        resolver: &ThresholdOverrideResolver,
5805        relative: &str,
5806        enforce_crap: bool,
5807        complexity: &[fallow_types::extract::FunctionComplexity],
5808    ) -> CrapThresholdSignals {
5809        let ceilings = CrapCeilingLookup::new(
5810            CrapScoreThresholds {
5811                resolver,
5812                enforce_crap,
5813            },
5814            std::path::Path::new(relative),
5815            &[],
5816        );
5817        compute_crap_scores_estimated(
5818            complexity,
5819            &rustc_hash::FxHashSet::default(),
5820            false,
5821            fallow_output::CoverageSource::Estimated,
5822            &ceilings,
5823        )
5824        .signals
5825    }
5826
5827    #[test]
5828    fn crap_counting_exempts_functions_under_override_ceiling() {
5829        // The issue's repro: two untested cyclomatic-10 functions (CRAP 110)
5830        // under an override raising maxCrap to 500 on the file.
5831        let resolver =
5832            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5833        let fns = vec![
5834            make_named_fn_complexity("a", 1, 10),
5835            make_named_fn_complexity("b", 12, 10),
5836        ];
5837
5838        let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5839        assert_eq!(covered.above, 0);
5840        assert_eq!(covered.exempted, 2);
5841        assert_eq!(covered.min_ceiling, Some(500.0));
5842
5843        let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
5844        assert_eq!(elsewhere.above, 2);
5845        assert_eq!(elsewhere.exempted, 0);
5846        assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
5847    }
5848
5849    #[test]
5850    fn crap_counting_insufficient_override_keeps_count() {
5851        let resolver =
5852            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
5853        let fns = vec![
5854            make_named_fn_complexity("a", 1, 10),
5855            make_named_fn_complexity("b", 12, 10),
5856        ];
5857
5858        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5859        assert_eq!(signals.above, 2);
5860        assert_eq!(signals.exempted, 0);
5861        assert_eq!(signals.min_ceiling, Some(50.0));
5862    }
5863
5864    #[test]
5865    fn crap_counting_partial_function_override() {
5866        // Only `a` is exempted; `b` keeps the global ceiling, which is also
5867        // the file's lowest effective ceiling.
5868        let resolver =
5869            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
5870        let fns = vec![
5871            make_named_fn_complexity("a", 1, 10),
5872            make_named_fn_complexity("b", 12, 10),
5873        ];
5874
5875        let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5876        assert_eq!(signals.above, 1);
5877        assert_eq!(signals.exempted, 1);
5878        assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
5879    }
5880
5881    #[test]
5882    fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
5883        // Global maxCrap 0 disables enforcement: nothing is above threshold
5884        // and every canonical-baseline breach is disclosed as exempt.
5885        let resolver = test_crap_resolver(0.0);
5886        let fns = vec![
5887            make_named_fn_complexity("a", 1, 10),
5888            make_named_fn_complexity("b", 12, 10),
5889            make_named_fn_complexity("tiny", 24, 1),
5890        ];
5891
5892        let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
5893        assert_eq!(signals.above, 0);
5894        assert_eq!(signals.exempted, 2);
5895    }
5896
5897    #[test]
5898    fn crap_counting_stricter_ceiling_never_counts_exempt() {
5899        // A ceiling below the canonical baseline flags the band between it and
5900        // 30 as above-threshold, never as exempt.
5901        let resolver = test_crap_resolver(10.0);
5902        let fns = vec![make_named_fn_complexity("a", 1, 4)]; // untested CRAP 20
5903
5904        let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
5905        assert_eq!(signals.above, 1);
5906        assert_eq!(signals.exempted, 0);
5907    }
5908
5909    #[test]
5910    fn crap_counting_uses_rounded_value_at_boundary() {
5911        // Istanbul coverage tuned so unrounded CRAP is 29.96, which rounds to
5912        // the stored per-function 30.0. The findings pipeline compares the
5913        // ROUNDED value against the ceiling and emits a finding; the count
5914        // must agree at the same boundary (issue #2228).
5915        let funcs = vec![make_fn_complexity(10)];
5916        let mut functions = rustc_hash::FxHashMap::default();
5917        functions.insert(("test_fn".to_string(), 1, 0), 41.56);
5918        let file_cov = test_istanbul_file_coverage(functions, false);
5919
5920        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5921        assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
5922        assert_eq!(result.signals.above, 1);
5923        assert_eq!(result.signals.exempted, 0);
5924    }
5925
5926    #[test]
5927    fn compute_file_scores_discloses_override_exemption_on_row() {
5928        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5929        let files = vec![crate::discover::DiscoveredFile {
5930            id: crate::discover::FileId(0),
5931            path: path_a.clone(),
5932            size_bytes: 100,
5933        }];
5934        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5935            file_id: crate::discover::FileId(0),
5936            path: path_a.clone(),
5937            ..Default::default()
5938        }];
5939        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5940        let modules = vec![make_module_info(
5941            0,
5942            26,
5943            vec![
5944                make_named_fn_complexity("a", 1, 10),
5945                make_named_fn_complexity("b", 12, 10),
5946            ],
5947        )];
5948        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5949            rustc_hash::FxHashMap::default();
5950        file_paths.insert(crate::discover::FileId(0), &files[0].path);
5951        let output = crate::results::DeadCodeAnalysisArtifacts {
5952            results: fallow_types::results::AnalysisResults::default(),
5953            timings: None,
5954            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5955            modules: None,
5956            files: None,
5957            script_used_packages: rustc_hash::FxHashSet::default(),
5958            trace_provenance: crate::trace::TraceProvenance::default(),
5959            file_hashes: rustc_hash::FxHashMap::default(),
5960        };
5961
5962        let resolver =
5963            test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5964        let result = compute_file_scores(
5965            FileScoreComputeInput {
5966                modules: &modules,
5967                file_paths: &file_paths,
5968                changed_files: None,
5969                istanbul_coverage: None,
5970                root: std::path::Path::new("/project"),
5971                crap_thresholds: CrapScoreThresholds {
5972                    resolver: &resolver,
5973                    enforce_crap: true,
5974                },
5975                with_static_links: false,
5976            },
5977            output,
5978        )
5979        .unwrap();
5980
5981        assert_eq!(result.scores.len(), 1);
5982        let score = &result.scores[0];
5983        assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
5984        assert_eq!(score.crap_above_threshold, 0);
5985        assert_eq!(score.crap_exempted, 2);
5986        assert_eq!(score.crap_effective_threshold, Some(500.0));
5987        assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
5988        assert_eq!(
5989            file_score_concern_axis(score, CRAP_THRESHOLD),
5990            FileScoreConcern::Structural
5991        );
5992    }
5993
5994    #[test]
5995    fn compute_file_scores_raised_global_omits_row_threshold() {
5996        let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5997        let files = vec![crate::discover::DiscoveredFile {
5998            id: crate::discover::FileId(0),
5999            path: path_a.clone(),
6000            size_bytes: 100,
6001        }];
6002        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
6003            file_id: crate::discover::FileId(0),
6004            path: path_a.clone(),
6005            ..Default::default()
6006        }];
6007        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
6008        let modules = vec![make_module_info(
6009            0,
6010            26,
6011            vec![
6012                make_named_fn_complexity("a", 1, 10),
6013                make_named_fn_complexity("b", 12, 10),
6014            ],
6015        )];
6016        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
6017            rustc_hash::FxHashMap::default();
6018        file_paths.insert(crate::discover::FileId(0), &files[0].path);
6019        let output = crate::results::DeadCodeAnalysisArtifacts {
6020            results: fallow_types::results::AnalysisResults::default(),
6021            timings: None,
6022            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
6023            modules: None,
6024            files: None,
6025            script_used_packages: rustc_hash::FxHashSet::default(),
6026            trace_provenance: crate::trace::TraceProvenance::default(),
6027            file_hashes: rustc_hash::FxHashMap::default(),
6028        };
6029
6030        // Raised via the global (`--max-crap 5000`), not an override: the row
6031        // must not repeat the run global as its own effective threshold.
6032        let resolver = test_crap_resolver(5000.0);
6033        let result = compute_file_scores(
6034            FileScoreComputeInput {
6035                modules: &modules,
6036                file_paths: &file_paths,
6037                changed_files: None,
6038                istanbul_coverage: None,
6039                root: std::path::Path::new("/project"),
6040                crap_thresholds: CrapScoreThresholds {
6041                    resolver: &resolver,
6042                    enforce_crap: true,
6043                },
6044                with_static_links: false,
6045            },
6046            output,
6047        )
6048        .unwrap();
6049
6050        assert_eq!(result.scores.len(), 1);
6051        let score = &result.scores[0];
6052        assert_eq!(score.crap_above_threshold, 0);
6053        assert_eq!(score.crap_exempted, 2);
6054        assert_eq!(score.crap_effective_threshold, None);
6055        assert!(file_score_fully_crap_exempt(score, 5000.0));
6056        assert_eq!(
6057            file_score_concern_axis(score, 5000.0),
6058            FileScoreConcern::Structural
6059        );
6060    }
6061
6062    /// Untested-file aggregation on the shipped estimated path: the reported
6063    /// max is the highest rounded score, `above` counts only the units at or
6064    /// over their effective ceiling, and the synthetic template and module
6065    /// units leave the CRAP dimension entirely.
6066    #[test]
6067    fn estimated_crap_untested_aggregates_over_real_units_only() {
6068        let funcs = vec![
6069            make_named_fn_complexity("below", 1, 4),
6070            make_named_fn_complexity("at_threshold", 2, 5),
6071            make_named_fn_complexity("above_threshold", 3, 8),
6072            make_named_fn_complexity("<template>", 4, 21),
6073            make_named_fn_complexity("<module>", 5, 21),
6074        ];
6075        let result = estimated_crap_default(
6076            &funcs,
6077            &rustc_hash::FxHashSet::default(),
6078            false,
6079            fallow_output::CoverageSource::Estimated,
6080        );
6081        assert!((result.max_crap - 72.0).abs() < f64::EPSILON, "{result:#?}");
6082        assert_eq!(result.signals.above, 2);
6083        assert_eq!(result.per_function.len(), 3);
6084    }
6085
6086    #[test]
6087    fn crap_formula_full_coverage() {
6088        let result = crap_formula(10.0, 100.0);
6089        assert!((result - 10.0).abs() < f64::EPSILON);
6090    }
6091
6092    #[test]
6093    fn crap_formula_zero_coverage() {
6094        let result = crap_formula(5.0, 0.0);
6095        assert!((result - 30.0).abs() < f64::EPSILON);
6096    }
6097
6098    #[test]
6099    fn crap_formula_partial_coverage() {
6100        let result = crap_formula(10.0, 50.0);
6101        assert!((result - 22.5).abs() < f64::EPSILON);
6102    }
6103
6104    #[test]
6105    fn crap_formula_high_coverage_low_complexity() {
6106        let result = crap_formula(2.0, 90.0);
6107        assert!((result - 2.004).abs() < 0.001);
6108    }
6109
6110    /// Pin the exact cyclomatic value at which the default CRAP gate (30.0)
6111    /// trips for each estimated-coverage tier. These numbers back the
6112    /// changelog and docs wording: 5 at the 0% tier, 10 at the 40% indirect
6113    /// tier, 28 at the 85% direct tier.
6114    #[test]
6115    fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
6116        for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
6117            assert!(
6118                crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
6119                "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
6120            );
6121            assert!(
6122                crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
6123                "cyclomatic {} at {coverage_pct}% must stay under the gate",
6124                gate_cc - 1.0
6125            );
6126        }
6127    }
6128
6129    #[test]
6130    fn istanbul_crap_excludes_synthetic_template_units() {
6131        let funcs = vec![
6132            make_named_fn_complexity("<template>", 1, 21),
6133            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
6134            make_fn_complexity(6),
6135        ];
6136        let result = istanbul_crap_default(&funcs, None, false);
6137        assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
6138        assert_eq!(result.signals.above, 1);
6139        assert_eq!(
6140            result.total, 1,
6141            "template units must not count as unmatched"
6142        );
6143        assert_eq!(result.per_function.len(), 1);
6144    }
6145
6146    #[test]
6147    fn estimated_crap_excludes_synthetic_template_units() {
6148        let funcs = vec![
6149            make_named_fn_complexity("<template>", 1, 21),
6150            make_named_fn_complexity("<snippet:rowBody>", 1, 16),
6151        ];
6152        let result = estimated_crap_default(
6153            &funcs,
6154            &rustc_hash::FxHashSet::default(),
6155            false,
6156            fallow_output::CoverageSource::Estimated,
6157        );
6158        assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
6159        assert_eq!(result.signals.above, 0);
6160        assert!(result.per_function.is_empty());
6161    }
6162
6163    #[test]
6164    fn istanbul_crap_with_coverage_data() {
6165        let funcs = vec![make_fn_complexity(10)];
6166        let mut functions = rustc_hash::FxHashMap::default();
6167        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
6168        let file_cov = test_istanbul_file_coverage(functions, false);
6169        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6170        assert!((result.max_crap - 10.8).abs() < 0.1);
6171        assert_eq!(result.signals.above, 0);
6172    }
6173
6174    #[test]
6175    fn istanbul_crap_falls_back_to_binary_when_no_match() {
6176        let funcs = vec![make_fn_complexity(6)];
6177        let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
6178        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6179        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
6180        assert_eq!(result.signals.above, 1);
6181    }
6182
6183    /// A file tests reach, with no coverage data for it at all, keeps the
6184    /// static estimate rather than being scored as fully covered.
6185    #[test]
6186    fn istanbul_crap_uses_the_static_estimate_when_no_file_coverage() {
6187        let funcs = vec![make_fn_complexity(5)];
6188        let result = istanbul_crap_default(&funcs, None, true);
6189        // The reported score is rounded to one decimal, so compare against
6190        // the estimate rather than the formula's last bit.
6191        assert!((result.max_crap - 10.4).abs() < 1e-9);
6192        assert_eq!(result.signals.above, 0);
6193    }
6194
6195    #[test]
6196    fn istanbul_crap_zero_coverage_matches_binary_untested() {
6197        let funcs = vec![make_fn_complexity(5)];
6198        let mut functions = rustc_hash::FxHashMap::default();
6199        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
6200        let file_cov = test_istanbul_file_coverage(functions, false);
6201        let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6202        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
6203        assert_eq!(result.signals.above, 1);
6204    }
6205
6206    #[test]
6207    fn estimated_crap_direct_test_reference() {
6208        let funcs = vec![make_fn_complexity(10)];
6209        let mut refs = rustc_hash::FxHashSet::default();
6210        refs.insert("test_fn".to_string());
6211        let result = estimated_crap_default(
6212            &funcs,
6213            &refs,
6214            true,
6215            fallow_output::CoverageSource::Estimated,
6216        );
6217        let (max, above) = (result.max_crap, result.signals.above);
6218        assert!((max - 10.3).abs() < 0.1);
6219        assert_eq!(above, 0);
6220    }
6221
6222    #[test]
6223    fn estimated_crap_context_name_is_not_a_direct_test_reference() {
6224        let mut generator = make_fn_complexity(10);
6225        generator.context_named = Some(fallow_types::extract::AnonymousFunctionKind::Function);
6226        let mut refs = rustc_hash::FxHashSet::default();
6227        refs.insert("test_fn".to_string());
6228        let with_context_name = estimated_crap_default(
6229            &[generator],
6230            &refs,
6231            true,
6232            fallow_output::CoverageSource::Estimated,
6233        );
6234        let without_reference = estimated_crap_default(
6235            &[make_fn_complexity(10)],
6236            &rustc_hash::FxHashSet::default(),
6237            true,
6238            fallow_output::CoverageSource::Estimated,
6239        );
6240        assert!((with_context_name.max_crap - without_reference.max_crap).abs() < f64::EPSILON);
6241    }
6242
6243    #[test]
6244    fn estimated_crap_indirect_test_reachable() {
6245        let funcs = vec![make_fn_complexity(10)];
6246        let refs = rustc_hash::FxHashSet::default();
6247        let result = estimated_crap_default(
6248            &funcs,
6249            &refs,
6250            true,
6251            fallow_output::CoverageSource::Estimated,
6252        );
6253        let (max, above) = (result.max_crap, result.signals.above);
6254        assert!((max - 31.6).abs() < 0.1);
6255        assert_eq!(above, 1);
6256    }
6257
6258    #[test]
6259    fn estimated_crap_untested_file() {
6260        let funcs = vec![make_fn_complexity(5)];
6261        let refs = rustc_hash::FxHashSet::default();
6262        let result = estimated_crap_default(
6263            &funcs,
6264            &refs,
6265            false,
6266            fallow_output::CoverageSource::Estimated,
6267        );
6268        let (max, above) = (result.max_crap, result.signals.above);
6269        assert!((max - 30.0).abs() < f64::EPSILON);
6270        assert_eq!(above, 1);
6271    }
6272
6273    #[test]
6274    fn estimated_crap_low_complexity_direct_ref() {
6275        let funcs = vec![make_fn_complexity(2)];
6276        let mut refs = rustc_hash::FxHashSet::default();
6277        refs.insert("test_fn".to_string());
6278        let result = estimated_crap_default(
6279            &funcs,
6280            &refs,
6281            true,
6282            fallow_output::CoverageSource::Estimated,
6283        );
6284        let (max, above) = (result.max_crap, result.signals.above);
6285        assert!(max < 3.0);
6286        assert_eq!(above, 0);
6287    }
6288
6289    #[test]
6290    fn estimated_crap_empty() {
6291        let refs = rustc_hash::FxHashSet::default();
6292        let result =
6293            estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
6294        let (max, above) = (result.max_crap, result.signals.above);
6295        assert!((max).abs() < f64::EPSILON);
6296        assert_eq!(above, 0);
6297    }
6298
6299    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
6300        fallow_graph::graph::ExportSymbol {
6301            name: fallow_types::extract::ExportName::Named(name.into()),
6302            is_type_only,
6303            is_side_effect_used: false,
6304            visibility: crate::source::VisibilityTag::None,
6305            expected_unused_reason: None,
6306            span: oxc_span::Span::default(),
6307            references: vec![],
6308            reference_paths: Vec::new(),
6309            members: vec![],
6310            deprecated: false,
6311            deprecated_reason: None,
6312        }
6313    }
6314
6315    #[test]
6316    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
6317        let path = std::path::Path::new("src/types.ts");
6318        let exports = vec![
6319            make_export("MyInterface", true),
6320            make_export("MyType", true),
6321            make_export("myFunction", false),
6322        ];
6323        let unused_files = rustc_hash::FxHashSet::default();
6324        let mut unused_by_path = rustc_hash::FxHashMap::default();
6325        unused_by_path.insert(path, 1_usize); // 1 unused value export
6326
6327        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6328        assert!((ratio - 1.0).abs() < f64::EPSILON);
6329    }
6330
6331    #[test]
6332    fn dead_code_ratio_only_type_exports_returns_zero() {
6333        let path = std::path::Path::new("src/types.ts");
6334        let exports = vec![
6335            make_export("MyInterface", true),
6336            make_export("MyType", true),
6337        ];
6338        let unused_files = rustc_hash::FxHashSet::default();
6339        let unused_by_path = rustc_hash::FxHashMap::default();
6340
6341        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6342        assert!(ratio.abs() < f64::EPSILON);
6343    }
6344
6345    #[test]
6346    fn dead_code_ratio_mixed_exports_counts_only_values() {
6347        let path = std::path::Path::new("src/component.ts");
6348        let exports = vec![
6349            make_export("Props", true),
6350            make_export("State", true),
6351            make_export("Component", false),
6352            make_export("helper", false),
6353        ];
6354        let unused_files = rustc_hash::FxHashSet::default();
6355        let mut unused_by_path = rustc_hash::FxHashMap::default();
6356        unused_by_path.insert(path, 1_usize);
6357
6358        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6359        assert!((ratio - 0.5).abs() < f64::EPSILON);
6360    }
6361
6362    fn write_single_file_istanbul_fixture(
6363        coverage_path: &std::path::Path,
6364        source_path: &std::path::Path,
6365        fn_map: &serde_json::Value,
6366        function_hits: &serde_json::Value,
6367    ) {
6368        write_single_file_istanbul_fixture_with_statements(
6369            coverage_path,
6370            source_path,
6371            fn_map,
6372            function_hits,
6373            &serde_json::json!({}),
6374            &serde_json::json!({}),
6375        );
6376    }
6377
6378    fn write_single_file_istanbul_fixture_with_statements(
6379        coverage_path: &std::path::Path,
6380        source_path: &std::path::Path,
6381        fn_map: &serde_json::Value,
6382        function_hits: &serde_json::Value,
6383        statement_map: &serde_json::Value,
6384        statement_hits: &serde_json::Value,
6385    ) {
6386        let mut root = serde_json::Map::new();
6387        root.insert(
6388            source_path.to_string_lossy().into_owned(),
6389            serde_json::json!({
6390                "path": source_path.to_string_lossy().into_owned(),
6391                "statementMap": statement_map,
6392                "fnMap": fn_map,
6393                "branchMap": {},
6394                "s": statement_hits,
6395                "f": function_hits,
6396                "b": {}
6397            }),
6398        );
6399
6400        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
6401    }
6402
6403    /// `v8-to-istanbul`, which is what c8 and nyc write, records `column: -1`
6404    /// for the implicit else of a bare `if`. Positions are unsigned, so the
6405    /// strict parse rejected the whole map over a coordinate in a section
6406    /// nothing here reads. Geometry from a map generated by running real V8
6407    /// coverage through v8-to-istanbul 9.2.0.
6408    #[test]
6409    fn a_negative_branch_coordinate_does_not_cost_the_whole_map() {
6410        let temp = tempfile::TempDir::new().unwrap();
6411        let source_path = temp.path().join("src/pick.ts");
6412        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6413        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6414
6415        let coverage_path = temp.path().join("coverage-final.json");
6416        let source = source_path.to_string_lossy().into_owned();
6417        std::fs::write(
6418            &coverage_path,
6419            serde_json::to_string(&serde_json::json!({
6420                source.clone(): {
6421                    "path": source,
6422                    "statementMap": {},
6423                    "fnMap": {
6424                        "0": {
6425                            "name": "pick",
6426                            "line": 1,
6427                            "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 20 } },
6428                            "loc": { "start": { "line": 1, "column": 41 }, "end": { "line": 6, "column": 1 } }
6429                        }
6430                    },
6431                    "branchMap": {
6432                        "0": {
6433                            "type": "branch",
6434                            "line": 5,
6435                            "loc": {
6436                                "start": { "line": 5, "column": -1 },
6437                                "end": { "line": 6, "column": 0 }
6438                            },
6439                            "locations": [
6440                                {
6441                                    "start": { "line": 5, "column": -1 },
6442                                    "end": { "line": 6, "column": 0 }
6443                                }
6444                            ]
6445                        }
6446                    },
6447                    "s": {},
6448                    "f": { "0": 2 },
6449                    "b": { "0": [1, 0] }
6450                }
6451            }))
6452            .unwrap(),
6453        )
6454        .unwrap();
6455
6456        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6457        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6458        let file_coverage = coverage.get(&canonical_source).unwrap();
6459
6460        assert_eq!(file_coverage.lookup("pick", 1, 16), Some(100.0));
6461    }
6462
6463    /// Raw V8 coverage and `oxc-coverage-instrument` record an accessor as
6464    /// `get area`, istanbul-lib-instrument leaves it anonymous, and fallow
6465    /// extracts the unit as `area`. Both spellings must reach the record.
6466    #[test]
6467    fn an_accessor_answers_to_its_property_name() {
6468        let temp = tempfile::TempDir::new().unwrap();
6469        let source_path = temp.path().join("src/box.ts");
6470        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6471        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6472
6473        let coverage_path = temp.path().join("coverage-final.json");
6474        write_single_file_istanbul_fixture(
6475            &coverage_path,
6476            &source_path,
6477            &serde_json::json!({
6478                "0": {
6479                    "name": "get area",
6480                    "line": 4,
6481                    "decl": { "start": { "line": 4, "column": 6 }, "end": { "line": 4, "column": 10 } },
6482                    "loc": { "start": { "line": 4, "column": 13 }, "end": { "line": 6, "column": 3 } }
6483                }
6484            }),
6485            &serde_json::json!({ "0": 0 }),
6486        );
6487
6488        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6489        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6490        let file_coverage = coverage.get(&canonical_source).unwrap();
6491
6492        // Fallow extracts the accessor under the property name alone.
6493        assert_eq!(file_coverage.lookup("area", 4, 6), Some(0.0));
6494        // The producer's own spelling still resolves.
6495        assert_eq!(file_coverage.lookup("get area", 4, 6), Some(0.0));
6496    }
6497
6498    #[test]
6499    fn resolve_relative_to_root_joins_relative_with_project_root() {
6500        let resolved = resolve_relative_to_root(
6501            std::path::Path::new("coverage/coverage-final.json"),
6502            Some(std::path::Path::new("/work/my-app")),
6503        );
6504        assert_eq!(
6505            resolved,
6506            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
6507        );
6508    }
6509
6510    #[test]
6511    fn resolve_relative_to_root_returns_absolute_unchanged() {
6512        let resolved = resolve_relative_to_root(
6513            std::path::Path::new("/tmp/coverage-final.json"),
6514            Some(std::path::Path::new("/work/my-app")),
6515        );
6516        assert_eq!(
6517            resolved,
6518            std::path::PathBuf::from("/tmp/coverage-final.json")
6519        );
6520    }
6521
6522    #[test]
6523    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
6524        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
6525        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
6526        assert_eq!(resolved, path);
6527    }
6528
6529    #[cfg(windows)]
6530    #[test]
6531    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
6532        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
6533        let resolved =
6534            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
6535        assert_eq!(resolved, path);
6536    }
6537
6538    #[test]
6539    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
6540        let resolved =
6541            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
6542        assert_eq!(
6543            resolved,
6544            std::path::PathBuf::from("coverage/coverage-final.json")
6545        );
6546    }
6547
6548    /// nyc and some Jest setups record project-relative keys. Resolving one
6549    /// against the process directory misses the file, and a run from anywhere
6550    /// but the project root loses the whole map at once.
6551    #[test]
6552    fn load_istanbul_coverage_resolves_relative_map_keys_against_project_root() {
6553        let temp = tempfile::TempDir::new().unwrap();
6554        let source_path = temp.path().join("src/index.ts");
6555        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6556        std::fs::write(&source_path, "export function f(){}").unwrap();
6557
6558        let coverage_path = temp.path().join("coverage-final.json");
6559        std::fs::write(
6560            &coverage_path,
6561            serde_json::to_string(&serde_json::json!({
6562                "src/index.ts": {
6563                    "path": "src/index.ts",
6564                    "statementMap": {},
6565                    "fnMap": {
6566                        "0": {
6567                            "name": "f",
6568                            "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6569                            "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6570                        }
6571                    },
6572                    "branchMap": {},
6573                    "s": {},
6574                    "f": { "0": 2 },
6575                    "b": {}
6576                }
6577            }))
6578            .unwrap(),
6579        )
6580        .unwrap();
6581
6582        let coverage =
6583            load_istanbul_coverage(&coverage_path, None, Some(temp.path()), false).unwrap();
6584        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6585        let file_coverage = coverage.get(&canonical_source).unwrap();
6586
6587        assert_eq!(file_coverage.lookup("f", 1, 0), Some(100.0));
6588    }
6589
6590    #[test]
6591    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
6592        let temp = tempfile::TempDir::new().unwrap();
6593        let source_path = temp.path().join("src/index.ts");
6594        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6595        std::fs::write(&source_path, "export function f(){}").unwrap();
6596
6597        let coverage_path = temp.path().join("coverage/coverage-final.json");
6598        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
6599        write_single_file_istanbul_fixture(
6600            &coverage_path,
6601            &source_path,
6602            &serde_json::json!({
6603                "0": {
6604                    "name": "f",
6605                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6606                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6607                }
6608            }),
6609            &serde_json::json!({ "0": 1 }),
6610        );
6611
6612        let coverage = load_istanbul_coverage(
6613            std::path::Path::new("coverage/coverage-final.json"),
6614            None,
6615            Some(temp.path()),
6616            false,
6617        )
6618        .expect("relative path must resolve against project_root");
6619        assert!(
6620            !coverage.files.is_empty(),
6621            "expected coverage to load via project_root resolution, got {} files",
6622            coverage.files.len()
6623        );
6624    }
6625
6626    #[test]
6627    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
6628        let temp = tempfile::TempDir::new().unwrap();
6629        let source_path = temp.path().join("src/service.ts");
6630        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6631        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
6632
6633        let coverage_path = temp.path().join("coverage-final.json");
6634        write_single_file_istanbul_fixture(
6635            &coverage_path,
6636            &source_path,
6637            &serde_json::json!({
6638                "0": {
6639                    "name": "(anonymous_0)",
6640                    "decl": {
6641                        "start": { "line": 5, "column": 2 },
6642                        "end": { "line": 5, "column": 13 }
6643                    },
6644                    "loc": {
6645                        "start": { "line": 5, "column": 14 },
6646                        "end": { "line": 11, "column": 3 }
6647                    }
6648                },
6649                "1": {
6650                    "name": "(anonymous_1)",
6651                    "decl": {
6652                        "start": { "line": 20, "column": 14 },
6653                        "end": { "line": 20, "column": 25 }
6654                    },
6655                    "loc": {
6656                        "start": { "line": 20, "column": 28 },
6657                        "end": { "line": 22, "column": 2 }
6658                    }
6659                }
6660            }),
6661            &serde_json::json!({
6662                "0": 1,
6663                "1": 0
6664            }),
6665        );
6666
6667        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6668        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6669        let file_coverage = coverage.get(&canonical_source).unwrap();
6670
6671        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
6672        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
6673    }
6674
6675    #[test]
6676    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
6677        let temp = tempfile::TempDir::new().unwrap();
6678        let source_path = temp.path().join("src/handler.ts");
6679        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6680        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
6681
6682        let coverage_path = temp.path().join("coverage-final.json");
6683        write_single_file_istanbul_fixture(
6684            &coverage_path,
6685            &source_path,
6686            &serde_json::json!({
6687                "0": {
6688                    "name": "handleClick",
6689                    "line": 40,
6690                    "decl": {
6691                        "start": { "line": 22, "column": 13 },
6692                        "end": { "line": 22, "column": 24 }
6693                    },
6694                    "loc": {
6695                        "start": { "line": 40, "column": 27 },
6696                        "end": { "line": 42, "column": 1 }
6697                    }
6698                }
6699            }),
6700            &serde_json::json!({
6701                "0": 1
6702            }),
6703        );
6704
6705        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6706        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6707        let file_coverage = coverage.get(&canonical_source).unwrap();
6708
6709        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
6710        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
6711    }
6712
6713    #[test]
6714    fn load_istanbul_coverage_indexes_valid_body_start_alias() {
6715        let temp = tempfile::TempDir::new().unwrap();
6716        let source_path = temp.path().join("src/handler.ts");
6717        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6718        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
6719
6720        let coverage_path = temp.path().join("coverage-final.json");
6721        write_single_file_istanbul_fixture(
6722            &coverage_path,
6723            &source_path,
6724            &serde_json::json!({
6725                "0": {
6726                    "name": "(anonymous_0)",
6727                    "line": 8,
6728                    "decl": {
6729                        "start": { "line": 8, "column": 14 },
6730                        "end": { "line": 8, "column": 25 }
6731                    },
6732                    "loc": {
6733                        "start": { "line": 20, "column": 6 },
6734                        "end": { "line": 22, "column": 1 }
6735                    }
6736                }
6737            }),
6738            &serde_json::json!({ "0": 1 }),
6739        );
6740
6741        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6742        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6743        let file_coverage = coverage.get(&canonical_source).unwrap();
6744
6745        assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
6746
6747        let mut function = make_fn_complexity(4);
6748        function.name = "handler".to_string();
6749        function.line = 20;
6750        function.col = 6;
6751        let result = istanbul_crap_default(&[function], Some(file_coverage), false);
6752        assert_eq!(result.matched, 1);
6753        assert_eq!(result.total, 1);
6754        assert_eq!(
6755            result.per_function[0].coverage_source,
6756            fallow_output::CoverageSource::Istanbul
6757        );
6758        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6759    }
6760
6761    /// A generator that a named arrow returns takes the arrow's name as a
6762    /// context name, but the coverage producer records it without a name. The
6763    /// name must not match the wrapper's record two columns away, so the
6764    /// lookup treats the generator as anonymous.
6765    #[test]
6766    fn a_context_named_generator_takes_its_own_anonymous_record() {
6767        let temp = tempfile::TempDir::new().unwrap();
6768        let source_path = temp.path().join("src/load.ts");
6769        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6770        std::fs::write(
6771            &source_path,
6772            "export const load = (id: string) =>\n  Effect.gen(function* () {\n    if (id) {}\n  });\n",
6773        )
6774        .unwrap();
6775
6776        let coverage_path = temp.path().join("coverage-final.json");
6777        write_single_file_istanbul_fixture(
6778            &coverage_path,
6779            &source_path,
6780            &serde_json::json!({
6781                "0": {
6782                    "name": "load",
6783                    "line": 1,
6784                    "decl": { "start": { "line": 1, "column": 20 }, "end": { "line": 1, "column": 21 } },
6785                    "loc": { "start": { "line": 1, "column": 20 }, "end": { "line": 4, "column": 4 } }
6786                },
6787                "1": {
6788                    "name": "(anonymous_1)",
6789                    "line": 2,
6790                    "decl": { "start": { "line": 2, "column": 13 }, "end": { "line": 2, "column": 14 } },
6791                    "loc": { "start": { "line": 2, "column": 13 }, "end": { "line": 4, "column": 3 } }
6792                }
6793            }),
6794            &serde_json::json!({ "0": 1, "1": 0 }),
6795        );
6796
6797        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6798        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6799        let file_coverage = coverage.get(&canonical_source).unwrap();
6800
6801        let mut generator = make_fn_complexity(2);
6802        generator.name = "load".to_string();
6803        generator.line = 2;
6804        generator.col = 13;
6805        assert_eq!(
6806            file_coverage.lookup_function(&generator),
6807            Some(100.0),
6808            "a declared name matches the wrapper record"
6809        );
6810        generator.context_named = Some(fallow_types::extract::AnonymousFunctionKind::Function);
6811        assert_eq!(file_coverage.lookup_function(&generator), Some(0.0));
6812    }
6813
6814    /// Curried arrows written one per line put each record's body start on
6815    /// the next record's declaration. The declaration is primary and wins the
6816    /// position, so each arrow keeps a record of its own and the innermost one
6817    /// resolves through the header span that opens at the arrow above it.
6818    /// Geometry from istanbul-lib-instrument 6 for the source below, which is
6819    /// what Prettier produces for a curried arrow.
6820    #[test]
6821    fn curried_arrows_one_per_line_each_take_their_own_record() {
6822        let temp = tempfile::TempDir::new().unwrap();
6823        let source_path = temp.path().join("src/adjust.ts");
6824        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6825        std::fs::write(
6826            &source_path,
6827            "export const adjust = (base: number) =>\n  (factor: number) =>\n  (offset: number) =>\n    base * factor + offset;\n",
6828        )
6829        .unwrap();
6830
6831        let coverage_path = temp.path().join("coverage-final.json");
6832        write_single_file_istanbul_fixture(
6833            &coverage_path,
6834            &source_path,
6835            &serde_json::json!({
6836                "0": {
6837                    "name": "(anonymous_0)",
6838                    "line": 2,
6839                    "decl": { "start": { "line": 1, "column": 22 }, "end": { "line": 1, "column": 23 } },
6840                    "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 26 } }
6841                },
6842                "1": {
6843                    "name": "(anonymous_1)",
6844                    "line": 3,
6845                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6846                    "loc": { "start": { "line": 3, "column": 2 }, "end": { "line": 4, "column": 26 } }
6847                },
6848                "2": {
6849                    "name": "(anonymous_2)",
6850                    "line": 4,
6851                    "decl": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 3 } },
6852                    "loc": { "start": { "line": 4, "column": 4 }, "end": { "line": 4, "column": 26 } }
6853                }
6854            }),
6855            &serde_json::json!({ "0": 2, "1": 1, "2": 0 }),
6856        );
6857
6858        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6859        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6860        let file_coverage = coverage.get(&canonical_source).unwrap();
6861
6862        assert_eq!(file_coverage.lookup("adjust", 1, 22), Some(100.0));
6863        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(100.0));
6864        // The innermost arrow never ran, and takes neither neighbour's value.
6865        assert_eq!(file_coverage.lookup("<arrow>", 3, 2), Some(0.0));
6866    }
6867
6868    /// A default value in a parameter list is inside the member's signature,
6869    /// and its own record can be anchored at the parameter rather than at the
6870    /// function, putting the extracted position tens of columns from the
6871    /// declaration. The record still owns that position, because its own
6872    /// signature span covers it. Geometry from an @vitest/coverage-istanbul
6873    /// map for a constructor whose parameter carries a default arrow.
6874    #[test]
6875    fn a_default_value_keeps_its_own_record_inside_the_enclosing_signature() {
6876        let temp = tempfile::TempDir::new().unwrap();
6877        let source_path = temp.path().join("src/filter.ts");
6878        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6879        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6880
6881        let coverage_path = temp.path().join("coverage-final.json");
6882        write_single_file_istanbul_fixture(
6883            &coverage_path,
6884            &source_path,
6885            &serde_json::json!({
6886                "0": {
6887                    "name": "(anonymous_0)",
6888                    "line": 173,
6889                    "decl": { "start": { "line": 169, "column": 2 }, "end": { "line": 170, "column": 3 } },
6890                    "loc": { "start": { "line": 173, "column": 4 }, "end": { "line": 182, "column": 3 } }
6891                },
6892                "1": {
6893                    "name": "(anonymous_1)",
6894                    "line": 171,
6895                    "decl": { "start": { "line": 171, "column": 21 }, "end": { "line": 171, "column": 67 } },
6896                    "loc": { "start": { "line": 171, "column": 67 }, "end": { "line": 171, "column": 76 } }
6897                }
6898            }),
6899            &serde_json::json!({ "0": 46, "1": 0 }),
6900        );
6901
6902        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6903        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6904        let file_coverage = coverage.get(&canonical_source).unwrap();
6905
6906        // Fallow extracts the arrow at its parameter paren, 40 columns right
6907        // of the record's declaration but inside the record's own span.
6908        assert_eq!(file_coverage.lookup("<arrow>", 171, 61), Some(0.0));
6909    }
6910
6911    /// A named function in a signature is a second function inside the
6912    /// member's header span, so the span identifies nothing on its own. A unit
6913    /// with no record of its own, such as a private member or a unit the
6914    /// producer names differently, must take the estimate rather than the
6915    /// coverage of whichever record happens to be near. Geometry from
6916    /// istanbul-lib-instrument 6 for the source below.
6917    #[test]
6918    fn header_span_abstains_when_another_function_is_declared_inside_it() {
6919        let temp = tempfile::TempDir::new().unwrap();
6920        let source_path = temp.path().join("src/chart.ts");
6921        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6922        std::fs::write(
6923            &source_path,
6924            "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",
6925        )
6926        .unwrap();
6927
6928        let coverage_path = temp.path().join("coverage-final.json");
6929        write_single_file_istanbul_fixture(
6930            &coverage_path,
6931            &source_path,
6932            &serde_json::json!({
6933                "0": {
6934                    "name": "(anonymous_0)",
6935                    "line": 10,
6936                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6937                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6938                },
6939                "1": {
6940                    "name": "scale",
6941                    "line": 7,
6942                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 28 } },
6943                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6944                }
6945            }),
6946            &serde_json::json!({ "0": 3, "1": 0 }),
6947        );
6948
6949        let canonical_source = dunce::canonicalize(&source_path).unwrap();
6950        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6951        let coverage = load_istanbul_coverage_for_sources(
6952            &coverage_path,
6953            None,
6954            Some(temp.path()),
6955            Some(&discovered_sources),
6956            false,
6957        )
6958        .unwrap();
6959        let file_coverage = coverage.get(&canonical_source).unwrap();
6960
6961        // Inside both the member's header span and `scale`'s, so neither says
6962        // anything about this position.
6963        assert_eq!(file_coverage.lookup("<anonymous>", 6, 6), None);
6964        // Both records still resolve.
6965        assert_eq!(file_coverage.lookup("scale", 5, 14), Some(0.0));
6966        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6967    }
6968
6969    /// A named function in a member signature can legally reuse the member's
6970    /// name. Name fuzz must not return that inner function before established
6971    /// anonymous resolution preserves the member's valid attribution.
6972    #[test]
6973    fn same_named_function_in_signature_does_not_supply_member_coverage() {
6974        let temp = tempfile::TempDir::new().unwrap();
6975        let source_path = temp.path().join("src/chart.ts");
6976        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6977        std::fs::write(
6978            &source_path,
6979            "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",
6980        )
6981        .unwrap();
6982
6983        let coverage_path = temp.path().join("coverage-final.json");
6984        write_single_file_istanbul_fixture(
6985            &coverage_path,
6986            &source_path,
6987            &serde_json::json!({
6988                "0": {
6989                    "name": "(anonymous_0)",
6990                    "line": 10,
6991                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6992                    "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6993                },
6994                "1": {
6995                    "name": "render",
6996                    "line": 7,
6997                    "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 29 } },
6998                    "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6999                }
7000            }),
7001            &serde_json::json!({ "0": 3, "1": 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("render", 3, 8), Some(100.0));
7009        assert_eq!(file_coverage.lookup("render", 5, 23), Some(0.0));
7010    }
7011
7012    /// A same-line signature can place an unrelated identifier exactly one
7013    /// function-expression prefix away from the member start. Column distance
7014    /// alone must not make that nested function the member's coverage source.
7015    #[test]
7016    fn same_line_same_named_function_in_signature_does_not_supply_member_coverage() {
7017        let temp = tempfile::TempDir::new().unwrap();
7018        let source_path = temp.path().join("src/chart.ts");
7019        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7020        std::fs::write(
7021            &source_path,
7022            "export class Chart {\n  @Watch(\"data\") render(x  = function  render() {}) {}\n}\n",
7023        )
7024        .unwrap();
7025
7026        let coverage_path = temp.path().join("coverage-final.json");
7027        write_single_file_istanbul_fixture(
7028            &coverage_path,
7029            &source_path,
7030            &serde_json::json!({
7031                "0": {
7032                    "name": "(anonymous_0)",
7033                    "line": 2,
7034                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7035                    "loc": { "start": { "line": 2, "column": 52 }, "end": { "line": 2, "column": 54 } }
7036                },
7037                "1": {
7038                    "name": "render",
7039                    "line": 2,
7040                    "decl": { "start": { "line": 2, "column": 39 }, "end": { "line": 2, "column": 45 } },
7041                    "loc": { "start": { "line": 2, "column": 48 }, "end": { "line": 2, "column": 50 } }
7042                }
7043            }),
7044            &serde_json::json!({ "0": 3, "1": 0 }),
7045        );
7046
7047        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7048        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7049        let coverage = load_istanbul_coverage_for_sources(
7050            &coverage_path,
7051            None,
7052            Some(temp.path()),
7053            Some(&discovered_sources),
7054            false,
7055        )
7056        .unwrap();
7057        let file_coverage = coverage.get(&canonical_source).unwrap();
7058
7059        assert_eq!(file_coverage.lookup("render", 2, 23), Some(100.0));
7060        assert_eq!(file_coverage.lookup("render", 2, 29), Some(0.0));
7061    }
7062
7063    #[test]
7064    fn named_generator_alias_handles_trivia_and_utf16_columns() {
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        let source_line =
7069            "  render(label = \"pi: π, mushroom: 🍄\", x = function/* gap */*render() {}) {}";
7070        std::fs::write(
7071            &source_path,
7072            format!("export class Chart {{\n{source_line}\n}}\n"),
7073        )
7074        .unwrap();
7075
7076        let utf16_column = |byte_column: usize| {
7077            u32::try_from(source_line[..byte_column].encode_utf16().count()).unwrap()
7078        };
7079        let outer_target_column = source_line.find("render(").unwrap() + "render".len();
7080        let syntax_column = source_line.find("function").unwrap();
7081        let name_column = source_line.find("*render").unwrap() + 1;
7082        let inner_body_column = source_line.find("{}").unwrap();
7083        let outer_body_column = source_line.rfind("{}").unwrap();
7084
7085        let coverage_path = temp.path().join("coverage-final.json");
7086        write_single_file_istanbul_fixture(
7087            &coverage_path,
7088            &source_path,
7089            &serde_json::json!({
7090                "0": {
7091                    "name": "(anonymous_0)",
7092                    "line": 2,
7093                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7094                    "loc": {
7095                        "start": { "line": 2, "column": utf16_column(outer_body_column) },
7096                        "end": { "line": 2, "column": utf16_column(outer_body_column + 2) }
7097                    }
7098                },
7099                "1": {
7100                    "name": "render",
7101                    "line": 2,
7102                    "decl": {
7103                        "start": { "line": 2, "column": utf16_column(name_column) },
7104                        "end": { "line": 2, "column": utf16_column(name_column + "render".len()) }
7105                    },
7106                    "loc": {
7107                        "start": { "line": 2, "column": utf16_column(inner_body_column) },
7108                        "end": { "line": 2, "column": utf16_column(inner_body_column + 2) }
7109                    }
7110                }
7111            }),
7112            &serde_json::json!({ "0": 3, "1": 0 }),
7113        );
7114
7115        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7116        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7117        let coverage = load_istanbul_coverage_for_sources(
7118            &coverage_path,
7119            None,
7120            Some(temp.path()),
7121            Some(&discovered_sources),
7122            false,
7123        )
7124        .unwrap();
7125        let file_coverage = coverage.get(&canonical_source).unwrap();
7126
7127        assert_eq!(
7128            file_coverage.lookup("render", 2, u32::try_from(outer_target_column).unwrap()),
7129            Some(100.0)
7130        );
7131        assert_eq!(
7132            file_coverage.lookup("render", 2, u32::try_from(syntax_column).unwrap()),
7133            Some(0.0)
7134        );
7135    }
7136
7137    #[test]
7138    fn utf16_index_is_sparse_and_rejects_surrogate_boundaries() {
7139        let ascii_prefix = "a".repeat(4_096);
7140        let source = format!("{ascii_prefix}🍄{}", "b".repeat(4_096));
7141        let index = IstanbulSourceIndex::new(&source, std::path::Path::new("minified.js"));
7142        let line_index = &index.non_ascii_lines[&0];
7143
7144        assert_eq!(line_index.checkpoints.len(), 1);
7145        assert_eq!(
7146            index.byte_position(1, 4_096),
7147            Some(IstanbulPosition::new(1, 4_096))
7148        );
7149        assert_eq!(index.byte_position(1, 4_097), None);
7150        assert_eq!(
7151            index.byte_position(1, 4_098),
7152            Some(IstanbulPosition::new(1, 4_100))
7153        );
7154        assert_eq!(
7155            index.byte_position(1, 8_194),
7156            Some(IstanbulPosition::new(1, 8_196))
7157        );
7158    }
7159
7160    #[test]
7161    fn effective_alias_normalizes_against_its_own_unicode_line() {
7162        let temp = tempfile::TempDir::new().unwrap();
7163        let source_path = temp.path().join("src/render.ts");
7164        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7165        std::fs::write(
7166            &source_path,
7167            "/*🍄*/ const placeholder = 0;\n/*π*/ function render() {}\n",
7168        )
7169        .unwrap();
7170
7171        let coverage_path = temp.path().join("coverage-final.json");
7172        write_single_file_istanbul_fixture(
7173            &coverage_path,
7174            &source_path,
7175            &serde_json::json!({
7176                "0": {
7177                    "name": "render",
7178                    "line": 1,
7179                    "decl": { "start": { "line": 2, "column": 15 }, "end": { "line": 2, "column": 21 } },
7180                    "loc": { "start": { "line": 2, "column": 24 }, "end": { "line": 2, "column": 26 } }
7181                }
7182            }),
7183            &serde_json::json!({ "0": 1 }),
7184        );
7185
7186        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7187        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7188        let coverage = load_istanbul_coverage_for_sources(
7189            &coverage_path,
7190            None,
7191            Some(temp.path()),
7192            Some(&discovered_sources),
7193            false,
7194        )
7195        .unwrap();
7196        let function = &coverage.get(&canonical_source).unwrap().functions[0];
7197
7198        assert!(
7199            function
7200                .aliases
7201                .iter()
7202                .any(|alias| { alias.position == IstanbulPosition::new(1, 17) && alias.primary })
7203        );
7204        assert!(
7205            !function
7206                .aliases
7207                .iter()
7208                .any(|alias| alias.position == IstanbulPosition::new(1, 16))
7209        );
7210    }
7211
7212    #[test]
7213    fn stale_utf16_coordinates_are_rejected_with_trusted_source() {
7214        let temp = tempfile::TempDir::new().unwrap();
7215        let source_path = temp.path().join("src/render.ts");
7216        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7217        std::fs::write(&source_path, "export function render() {}\n").unwrap();
7218
7219        let coverage_path = temp.path().join("coverage-final.json");
7220        write_single_file_istanbul_fixture(
7221            &coverage_path,
7222            &source_path,
7223            &serde_json::json!({
7224                "0": {
7225                    "name": "render",
7226                    "line": 1,
7227                    "decl": { "start": { "line": 1, "column": 999 }, "end": { "line": 1, "column": 22 } },
7228                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
7229                }
7230            }),
7231            &serde_json::json!({ "0": 1 }),
7232        );
7233
7234        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7235        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7236        let coverage = load_istanbul_coverage_for_sources(
7237            &coverage_path,
7238            None,
7239            Some(temp.path()),
7240            Some(&discovered_sources),
7241            false,
7242        )
7243        .unwrap();
7244
7245        assert_eq!(
7246            coverage
7247                .get(&canonical_source)
7248                .unwrap()
7249                .lookup("render", 1, 7),
7250            None
7251        );
7252    }
7253
7254    #[test]
7255    fn invalid_optional_coordinates_preserve_valid_declaration_attribution() {
7256        let temp = tempfile::TempDir::new().unwrap();
7257        let source_path = temp.path().join("src/render.ts");
7258        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7259        std::fs::write(&source_path, "export function render() {}\n").unwrap();
7260
7261        let coverage_path = temp.path().join("coverage-final.json");
7262        write_single_file_istanbul_fixture(
7263            &coverage_path,
7264            &source_path,
7265            &serde_json::json!({
7266                "0": {
7267                    "name": "render",
7268                    "line": 99,
7269                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 99, "column": 999 } },
7270                    "loc": { "start": { "line": 1, "column": 999 }, "end": { "line": 99, "column": 999 } }
7271                }
7272            }),
7273            &serde_json::json!({ "0": 1 }),
7274        );
7275
7276        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7277        let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7278        let coverage = load_istanbul_coverage_for_sources(
7279            &coverage_path,
7280            None,
7281            Some(temp.path()),
7282            Some(&discovered_sources),
7283            false,
7284        )
7285        .unwrap();
7286        let file_coverage = coverage.get(&canonical_source).unwrap();
7287        let function = &file_coverage.functions[0];
7288
7289        assert_eq!(file_coverage.lookup("render", 1, 16), Some(100.0));
7290        assert!(function.body_span.is_none());
7291        assert!(function.header_span.is_none());
7292        assert!(
7293            !function
7294                .aliases
7295                .iter()
7296                .any(|alias| { alias.position.line == 99 || alias.position.col == 999 })
7297        );
7298    }
7299
7300    #[test]
7301    fn malformed_source_does_not_supply_named_function_provenance() {
7302        assert!(
7303            !IstanbulSourceIndex::new(
7304                "export function render() {}",
7305                std::path::Path::new("valid.ts"),
7306            )
7307            .named_function_starts
7308            .is_empty()
7309        );
7310        let index = IstanbulSourceIndex::new(
7311            "export function render() {} const broken = ;",
7312            std::path::Path::new("broken.ts"),
7313        );
7314
7315        assert!(index.named_function_starts.is_empty());
7316    }
7317
7318    #[test]
7319    fn javascript_with_jsx_uses_clean_jsx_provenance_parse() {
7320        let index = IstanbulSourceIndex::new(
7321            "export function render() { return <div />; }",
7322            std::path::Path::new("component.js"),
7323        );
7324
7325        assert!(!index.named_function_starts.is_empty());
7326    }
7327
7328    #[test]
7329    fn undiscovered_coverage_path_is_not_loaded_for_source_provenance() {
7330        let temp = tempfile::TempDir::new().unwrap();
7331        let source_path = temp.path().join("src/excluded.ts");
7332        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7333        std::fs::write(&source_path, "export function render() {}\n").unwrap();
7334
7335        let coverage_path = temp.path().join("coverage-final.json");
7336        write_single_file_istanbul_fixture(
7337            &coverage_path,
7338            &source_path,
7339            &serde_json::json!({
7340                "0": {
7341                    "name": "render",
7342                    "line": 1,
7343                    "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 22 } },
7344                    "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
7345                }
7346            }),
7347            &serde_json::json!({ "0": 1 }),
7348        );
7349
7350        let coverage = load_istanbul_coverage_for_sources(
7351            &coverage_path,
7352            None,
7353            Some(temp.path()),
7354            Some(&rustc_hash::FxHashSet::default()),
7355            false,
7356        )
7357        .unwrap();
7358        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7359        let function = &coverage.get(&canonical_source).unwrap().functions[0];
7360
7361        assert!(
7362            function
7363                .aliases
7364                .iter()
7365                .all(|alias| alias.position != IstanbulPosition::new(1, 7))
7366        );
7367    }
7368
7369    /// No instrumenter emits an `fnMap` identity for a private class member,
7370    /// so any candidate one reaches belongs to an enclosing function.
7371    #[test]
7372    fn private_class_member_never_takes_enclosing_coverage() {
7373        let temp = tempfile::TempDir::new().unwrap();
7374        let source_path = temp.path().join("src/vault.js");
7375        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7376        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7377
7378        let coverage_path = temp.path().join("coverage-final.json");
7379        write_single_file_istanbul_fixture(
7380            &coverage_path,
7381            &source_path,
7382            &serde_json::json!({
7383                "0": {
7384                    "name": "(anonymous_0)",
7385                    "line": 7,
7386                    "decl": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 25 } },
7387                    "loc": { "start": { "line": 7, "column": 5 }, "end": { "line": 7, "column": 20 } }
7388                }
7389            }),
7390            &serde_json::json!({ "0": 1 }),
7391        );
7392
7393        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7394        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7395        let file_coverage = coverage.get(&canonical_source).unwrap();
7396
7397        // `#wipe` sits in the arrow's header span and the arrow ran, but the
7398        // private member has no record of its own and never ran.
7399        let mut private_member = make_fn_complexity(1);
7400        private_member.name = "#wipe".to_string();
7401        private_member.is_private_member = true;
7402        private_member.line = 3;
7403        private_member.col = 9;
7404        let result = istanbul_crap_default(&[private_member], Some(file_coverage), false);
7405        assert_eq!(result.matched, 0);
7406        assert_eq!(
7407            result.per_function[0].coverage_source,
7408            fallow_output::CoverageSource::Estimated
7409        );
7410        assert_eq!(file_coverage.lookup("<arrow>", 1, 24), Some(100.0));
7411    }
7412
7413    #[test]
7414    fn quoted_hash_method_keeps_exact_istanbul_coverage() {
7415        let temp = tempfile::TempDir::new().unwrap();
7416        let source_path = temp.path().join("src/vault.js");
7417        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7418        std::fs::write(&source_path, "class Vault { '#wipe'() {} }\n").unwrap();
7419
7420        let coverage_path = temp.path().join("coverage-final.json");
7421        write_single_file_istanbul_fixture(
7422            &coverage_path,
7423            &source_path,
7424            &serde_json::json!({
7425                "0": {
7426                    "name": "#wipe",
7427                    "line": 1,
7428                    "decl": { "start": { "line": 1, "column": 14 }, "end": { "line": 1, "column": 21 } },
7429                    "loc": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 26 } }
7430                }
7431            }),
7432            &serde_json::json!({ "0": 1 }),
7433        );
7434
7435        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7436        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7437        let file_coverage = coverage.get(&canonical_source).unwrap();
7438
7439        let mut quoted_public_method = make_fn_complexity(1);
7440        quoted_public_method.name = "#wipe".to_string();
7441        quoted_public_method.line = 1;
7442        quoted_public_method.col = 14;
7443        let result = istanbul_crap_default(&[quoted_public_method], Some(file_coverage), false);
7444        assert_eq!(result.matched, 1);
7445        assert_eq!(
7446            result.per_function[0].coverage_source,
7447            fallow_output::CoverageSource::Istanbul
7448        );
7449        assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
7450    }
7451
7452    /// A decorated member's `decl` opens at the decorator and its `loc` opens
7453    /// at the body brace, so the extracted position sits between them with no
7454    /// alias in reach: the decorator is more than
7455    /// `ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT` columns to the left and the body
7456    /// is more than `ALIAS_FUZZ_MAX_LINE_DRIFT` lines below. The header span
7457    /// is what identifies the member. Geometry from istanbul-lib-instrument 6
7458    /// for the source below, which is ordinary NestJS, Angular, and TypeORM
7459    /// shape rather than a corner case.
7460    #[test]
7461    fn decorated_member_matches_its_istanbul_header_span() {
7462        let temp = tempfile::TempDir::new().unwrap();
7463        let source_path = temp.path().join("src/users.controller.ts");
7464        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7465        std::fs::write(
7466            &source_path,
7467            "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",
7468        )
7469        .unwrap();
7470
7471        let coverage_path = temp.path().join("coverage-final.json");
7472        write_single_file_istanbul_fixture(
7473            &coverage_path,
7474            &source_path,
7475            &serde_json::json!({
7476                "0": {
7477                    "name": "(anonymous_0)",
7478                    "line": 6,
7479                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7480                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7481                }
7482            }),
7483            &serde_json::json!({ "0": 3 }),
7484        );
7485
7486        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7487        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7488        let file_coverage = coverage.get(&canonical_source).unwrap();
7489
7490        // Fallow extracts the member at the parameter paren, 3:26.
7491        assert_eq!(
7492            file_coverage.lookup("findOneWithProfile", 3, 26),
7493            Some(100.0)
7494        );
7495    }
7496
7497    /// A function written in a signature has a record of its own, and the
7498    /// signature it sits in belongs to a different function. The record wins:
7499    /// crediting the enclosing member would report the member's coverage for
7500    /// a default value that never ran. Geometry from istanbul-lib-instrument 6
7501    /// for the source below.
7502    #[test]
7503    fn established_alias_wins_over_the_signature_that_contains_it() {
7504        let temp = tempfile::TempDir::new().unwrap();
7505        let source_path = temp.path().join("src/chart.ts");
7506        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7507        std::fs::write(
7508            &source_path,
7509            "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",
7510        )
7511        .unwrap();
7512
7513        let coverage_path = temp.path().join("coverage-final.json");
7514        write_single_file_istanbul_fixture(
7515            &coverage_path,
7516            &source_path,
7517            &serde_json::json!({
7518                "0": {
7519                    "name": "(anonymous_0)",
7520                    "line": 6,
7521                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7522                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7523                },
7524                "1": {
7525                    "name": "(anonymous_1)",
7526                    "line": 5,
7527                    "decl": { "start": { "line": 5, "column": 14 }, "end": { "line": 5, "column": 15 } },
7528                    "loc": { "start": { "line": 5, "column": 31 }, "end": { "line": 5, "column": 38 } }
7529                }
7530            }),
7531            &serde_json::json!({ "0": 3, "1": 0 }),
7532        );
7533
7534        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7535        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7536        let file_coverage = coverage.get(&canonical_source).unwrap();
7537
7538        // The default value takes its own record, not the method's 100.
7539        assert_eq!(file_coverage.lookup("<arrow>", 5, 14), Some(0.0));
7540        // The method still resolves, and not to its default value.
7541        assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7542    }
7543
7544    /// A member whose signature holds a function of its own has two records a
7545    /// line or two apart, and the member's extracted position is closer to
7546    /// the inner one. Proximity would report the default value's coverage for
7547    /// the member, and the header span cannot break the tie because it
7548    /// contains both. Abstaining leaves the static estimate, which is wrong by
7549    /// a known amount rather than wrong while claiming to be measured.
7550    /// Geometry from istanbul-lib-instrument 6 for the source below.
7551    #[test]
7552    fn signature_holding_a_function_abstains_instead_of_crediting_it() {
7553        let temp = tempfile::TempDir::new().unwrap();
7554        let source_path = temp.path().join("src/users.controller.ts");
7555        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7556        std::fs::write(
7557            &source_path,
7558            "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",
7559        )
7560        .unwrap();
7561
7562        let coverage_path = temp.path().join("coverage-final.json");
7563        write_single_file_istanbul_fixture(
7564            &coverage_path,
7565            &source_path,
7566            &serde_json::json!({
7567                "0": {
7568                    "name": "(anonymous_0)",
7569                    "line": 6,
7570                    "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7571                    "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7572                },
7573                "1": {
7574                    "name": "(anonymous_1)",
7575                    "line": 5,
7576                    "decl": { "start": { "line": 5, "column": 16 }, "end": { "line": 5, "column": 17 } },
7577                    "loc": { "start": { "line": 5, "column": 33 }, "end": { "line": 5, "column": 43 } }
7578                }
7579            }),
7580            &serde_json::json!({ "0": 3, "1": 0 }),
7581        );
7582
7583        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7584        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7585        let file_coverage = coverage.get(&canonical_source).unwrap();
7586
7587        // The member is two lines from the default value's declaration and
7588        // ten columns off it, well inside the fallback's reach.
7589        assert_eq!(file_coverage.lookup("findOneWithProfile", 3, 26), None);
7590        // The default value itself still resolves.
7591        assert_eq!(file_coverage.lookup("<arrow>", 5, 16), Some(0.0));
7592    }
7593
7594    #[test]
7595    fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
7596        let temp = tempfile::TempDir::new().unwrap();
7597        let source_path = temp.path().join("src/aliases.ts");
7598        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7599        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7600
7601        let coverage_path = temp.path().join("coverage-final.json");
7602        write_single_file_istanbul_fixture(
7603            &coverage_path,
7604            &source_path,
7605            &serde_json::json!({
7606                "0": {
7607                    "name": "(anonymous_0)",
7608                    "line": 10,
7609                    "decl": {
7610                        "start": { "line": 10, "column": 8 },
7611                        "end": { "line": 10, "column": 9 }
7612                    },
7613                    "loc": {
7614                        "start": { "line": 12, "column": 8 },
7615                        "end": { "line": 13, "column": 1 }
7616                    }
7617                }
7618            }),
7619            &serde_json::json!({ "0": 1 }),
7620        );
7621
7622        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7623        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7624        let file_coverage = coverage.get(&canonical_source).unwrap();
7625
7626        assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
7627    }
7628
7629    /// istanbul-lib-instrument geometry for
7630    /// `export const nested = () => () => true;`: the outer arrow's `loc` is
7631    /// its expression body, which starts exactly where the inner arrow is
7632    /// declared.
7633    #[test]
7634    fn curried_arrow_one_liner_resolves_both_arrows() {
7635        let temp = tempfile::TempDir::new().unwrap();
7636        let source_path = temp.path().join("src/nested.ts");
7637        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7638        std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
7639
7640        let coverage_path = temp.path().join("coverage-final.json");
7641        write_single_file_istanbul_fixture(
7642            &coverage_path,
7643            &source_path,
7644            &serde_json::json!({
7645                "0": {
7646                    "name": "(anonymous_0)",
7647                    "line": 1,
7648                    "decl": {
7649                        "start": { "line": 1, "column": 22 },
7650                        "end": { "line": 1, "column": 23 }
7651                    },
7652                    "loc": {
7653                        "start": { "line": 1, "column": 28 },
7654                        "end": { "line": 1, "column": 38 }
7655                    }
7656                },
7657                "1": {
7658                    "name": "(anonymous_1)",
7659                    "line": 1,
7660                    "decl": {
7661                        "start": { "line": 1, "column": 28 },
7662                        "end": { "line": 1, "column": 29 }
7663                    },
7664                    "loc": {
7665                        "start": { "line": 1, "column": 34 },
7666                        "end": { "line": 1, "column": 38 }
7667                    }
7668                }
7669            }),
7670            &serde_json::json!({ "0": 1, "1": 0 }),
7671        );
7672
7673        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7674        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7675        let file_coverage = coverage.get(&canonical_source).unwrap();
7676
7677        assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
7678        assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
7679    }
7680
7681    /// A nested function body sits inside its enclosing function's `loc`, so
7682    /// counting every contained statement charged the inner body's misses to
7683    /// the outer record as well.
7684    ///
7685    /// Geometry recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7686    ///
7687    /// ```js
7688    /// function outer() {
7689    ///   return function inner(flag) {
7690    ///     if (flag) return "yes";
7691    ///     return "no";
7692    ///   };
7693    /// }
7694    ///
7695    /// module.exports = { outer };
7696    /// ```
7697    ///
7698    /// with `outer()` called once and `inner` never called. Statement 0 is
7699    /// the outer return and is hit, statements 1 to 3 are the inner body and
7700    /// are not, statement 4 is the module-scope export.
7701    #[test]
7702    fn nested_function_statements_do_not_lower_the_outer_function() {
7703        let temp = tempfile::TempDir::new().unwrap();
7704        let source_path = temp.path().join("src/nested.js");
7705        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7706        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7707
7708        let coverage_path = temp.path().join("coverage-final.json");
7709        write_single_file_istanbul_fixture_with_statements(
7710            &coverage_path,
7711            &source_path,
7712            &nested_return_fn_map(),
7713            &serde_json::json!({ "0": 1, "1": 0 }),
7714            &nested_return_statement_map(),
7715            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 1 }),
7716        );
7717
7718        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7719        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7720        let file_coverage = coverage.get(&canonical_source).unwrap();
7721
7722        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7723        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7724    }
7725
7726    /// A statement outside every `fnMap` record belongs to no function. The
7727    /// module-scope export in the fixture above is hit, and flipping it to
7728    /// unhit must not move either function.
7729    #[test]
7730    fn module_scope_statements_belong_to_no_function() {
7731        let temp = tempfile::TempDir::new().unwrap();
7732        let source_path = temp.path().join("src/nested.js");
7733        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7734        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7735
7736        let coverage_path = temp.path().join("coverage-final.json");
7737        write_single_file_istanbul_fixture_with_statements(
7738            &coverage_path,
7739            &source_path,
7740            &nested_return_fn_map(),
7741            &serde_json::json!({ "0": 1, "1": 0 }),
7742            &nested_return_statement_map(),
7743            &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 0 }),
7744        );
7745
7746        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7747        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7748        let file_coverage = coverage.get(&canonical_source).unwrap();
7749
7750        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7751        assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7752    }
7753
7754    /// Ownership can leave a record with no statements of its own, and that
7755    /// record still has to fall back to the function hit count. Geometry
7756    /// recorded verbatim from istanbul-lib-instrument 6.0.3 for:
7757    ///
7758    /// ```js
7759    /// function outer() {
7760    ///   function inner(flag) {
7761    ///     if (flag) return "yes";
7762    ///     return "no";
7763    ///   }
7764    /// }
7765    ///
7766    /// module.exports = { outer };
7767    /// ```
7768    ///
7769    /// with `outer()` called once. A hoisted declaration is not a statement,
7770    /// so every statement inside `outer` belongs to `inner`.
7771    #[test]
7772    fn an_outer_function_with_only_a_nested_body_falls_back_to_the_hit_count() {
7773        let temp = tempfile::TempDir::new().unwrap();
7774        let source_path = temp.path().join("src/decl-nested.js");
7775        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7776        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7777
7778        let coverage_path = temp.path().join("coverage-final.json");
7779        write_single_file_istanbul_fixture_with_statements(
7780            &coverage_path,
7781            &source_path,
7782            &serde_json::json!({
7783                "0": {
7784                    "name": "outer",
7785                    "line": 1,
7786                    "decl": {
7787                        "start": { "line": 1, "column": 9 },
7788                        "end": { "line": 1, "column": 14 }
7789                    },
7790                    "loc": {
7791                        "start": { "line": 1, "column": 17 },
7792                        "end": { "line": 6, "column": 1 }
7793                    }
7794                },
7795                "1": {
7796                    "name": "inner",
7797                    "line": 2,
7798                    "decl": {
7799                        "start": { "line": 2, "column": 11 },
7800                        "end": { "line": 2, "column": 16 }
7801                    },
7802                    "loc": {
7803                        "start": { "line": 2, "column": 23 },
7804                        "end": { "line": 5, "column": 3 }
7805                    }
7806                }
7807            }),
7808            &serde_json::json!({ "0": 1, "1": 0 }),
7809            &serde_json::json!({
7810                "0": {
7811                    "start": { "line": 3, "column": 4 },
7812                    "end": { "line": 3, "column": 27 }
7813                },
7814                "1": {
7815                    "start": { "line": 3, "column": 14 },
7816                    "end": { "line": 3, "column": 27 }
7817                },
7818                "2": {
7819                    "start": { "line": 4, "column": 4 },
7820                    "end": { "line": 4, "column": 16 }
7821                },
7822                "3": {
7823                    "start": { "line": 8, "column": 0 },
7824                    "end": { "line": 8, "column": 27 }
7825                }
7826            }),
7827            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1 }),
7828        );
7829
7830        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7831        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7832        let file_coverage = coverage.get(&canonical_source).unwrap();
7833
7834        assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7835        assert_eq!(file_coverage.lookup("inner", 2, 11), Some(0.0));
7836    }
7837
7838    /// Two functions at module scope never contain each other, so ownership
7839    /// must leave them exactly where they were. Geometry recorded verbatim
7840    /// from istanbul-lib-instrument 6.0.3 for the same source with `inner`
7841    /// hoisted out of `outer`.
7842    #[test]
7843    fn sibling_functions_keep_independent_statement_coverage() {
7844        let temp = tempfile::TempDir::new().unwrap();
7845        let source_path = temp.path().join("src/sibling.js");
7846        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7847        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7848
7849        let coverage_path = temp.path().join("coverage-final.json");
7850        write_single_file_istanbul_fixture_with_statements(
7851            &coverage_path,
7852            &source_path,
7853            &serde_json::json!({
7854                "0": {
7855                    "name": "inner",
7856                    "line": 1,
7857                    "decl": {
7858                        "start": { "line": 1, "column": 9 },
7859                        "end": { "line": 1, "column": 14 }
7860                    },
7861                    "loc": {
7862                        "start": { "line": 1, "column": 21 },
7863                        "end": { "line": 4, "column": 1 }
7864                    }
7865                },
7866                "1": {
7867                    "name": "outer",
7868                    "line": 6,
7869                    "decl": {
7870                        "start": { "line": 6, "column": 9 },
7871                        "end": { "line": 6, "column": 14 }
7872                    },
7873                    "loc": {
7874                        "start": { "line": 6, "column": 17 },
7875                        "end": { "line": 8, "column": 1 }
7876                    }
7877                }
7878            }),
7879            &serde_json::json!({ "0": 0, "1": 1 }),
7880            &serde_json::json!({
7881                "0": {
7882                    "start": { "line": 2, "column": 2 },
7883                    "end": { "line": 2, "column": 25 }
7884                },
7885                "1": {
7886                    "start": { "line": 2, "column": 12 },
7887                    "end": { "line": 2, "column": 25 }
7888                },
7889                "2": {
7890                    "start": { "line": 3, "column": 2 },
7891                    "end": { "line": 3, "column": 14 }
7892                },
7893                "3": {
7894                    "start": { "line": 7, "column": 2 },
7895                    "end": { "line": 7, "column": 15 }
7896                },
7897                "4": {
7898                    "start": { "line": 10, "column": 0 },
7899                    "end": { "line": 10, "column": 27 }
7900                }
7901            }),
7902            &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1, "4": 1 }),
7903        );
7904
7905        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7906        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7907        let file_coverage = coverage.get(&canonical_source).unwrap();
7908
7909        assert_eq!(file_coverage.lookup("outer", 6, 9), Some(100.0));
7910        assert_eq!(file_coverage.lookup("inner", 1, 9), Some(0.0));
7911    }
7912
7913    /// Curried arrows share their closing position, so the inner body is
7914    /// distinguished only by its later start. Geometry recorded verbatim from
7915    /// istanbul-lib-instrument 6.0.3 for `const add = (a) => (b) => a + b;`
7916    /// with `add(1)` called once, so the outer arrow ran and the inner did
7917    /// not. Statement 1 is the outer expression body, statement 2 the inner.
7918    #[test]
7919    fn curried_arrow_statements_belong_to_the_innermost_arrow() {
7920        let temp = tempfile::TempDir::new().unwrap();
7921        let source_path = temp.path().join("src/curried.js");
7922        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7923        std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7924
7925        let coverage_path = temp.path().join("coverage-final.json");
7926        write_single_file_istanbul_fixture_with_statements(
7927            &coverage_path,
7928            &source_path,
7929            &serde_json::json!({
7930                "0": {
7931                    "name": "(anonymous_0)",
7932                    "line": 1,
7933                    "decl": {
7934                        "start": { "line": 1, "column": 12 },
7935                        "end": { "line": 1, "column": 13 }
7936                    },
7937                    "loc": {
7938                        "start": { "line": 1, "column": 19 },
7939                        "end": { "line": 1, "column": 31 }
7940                    }
7941                },
7942                "1": {
7943                    "name": "(anonymous_1)",
7944                    "line": 1,
7945                    "decl": {
7946                        "start": { "line": 1, "column": 19 },
7947                        "end": { "line": 1, "column": 20 }
7948                    },
7949                    "loc": {
7950                        "start": { "line": 1, "column": 26 },
7951                        "end": { "line": 1, "column": 31 }
7952                    }
7953                }
7954            }),
7955            &serde_json::json!({ "0": 1, "1": 0 }),
7956            &serde_json::json!({
7957                "0": {
7958                    "start": { "line": 1, "column": 12 },
7959                    "end": { "line": 1, "column": 31 }
7960                },
7961                "1": {
7962                    "start": { "line": 1, "column": 19 },
7963                    "end": { "line": 1, "column": 31 }
7964                },
7965                "2": {
7966                    "start": { "line": 1, "column": 26 },
7967                    "end": { "line": 1, "column": 31 }
7968                },
7969                "3": {
7970                    "start": { "line": 3, "column": 0 },
7971                    "end": { "line": 3, "column": 25 }
7972                }
7973            }),
7974            &serde_json::json!({ "0": 1, "1": 1, "2": 0, "3": 1 }),
7975        );
7976
7977        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7978        let canonical_source = dunce::canonicalize(&source_path).unwrap();
7979        let file_coverage = coverage.get(&canonical_source).unwrap();
7980
7981        assert_eq!(file_coverage.lookup("add", 1, 12), Some(100.0));
7982        assert_eq!(file_coverage.lookup("<arrow>", 1, 19), Some(0.0));
7983    }
7984
7985    /// The `fnMap` and `statementMap` istanbul-lib-instrument 6.0.3 records
7986    /// for the nested-return source, shared by the two tests that pin
7987    /// statement ownership against it.
7988    fn nested_return_fn_map() -> serde_json::Value {
7989        serde_json::json!({
7990            "0": {
7991                "name": "outer",
7992                "line": 1,
7993                "decl": {
7994                    "start": { "line": 1, "column": 9 },
7995                    "end": { "line": 1, "column": 14 }
7996                },
7997                "loc": {
7998                    "start": { "line": 1, "column": 17 },
7999                    "end": { "line": 6, "column": 1 }
8000                }
8001            },
8002            "1": {
8003                "name": "inner",
8004                "line": 2,
8005                "decl": {
8006                    "start": { "line": 2, "column": 18 },
8007                    "end": { "line": 2, "column": 23 }
8008                },
8009                "loc": {
8010                    "start": { "line": 2, "column": 30 },
8011                    "end": { "line": 5, "column": 3 }
8012                }
8013            }
8014        })
8015    }
8016
8017    fn nested_return_statement_map() -> serde_json::Value {
8018        serde_json::json!({
8019            "0": {
8020                "start": { "line": 2, "column": 2 },
8021                "end": { "line": 5, "column": 4 }
8022            },
8023            "1": {
8024                "start": { "line": 3, "column": 4 },
8025                "end": { "line": 3, "column": 27 }
8026            },
8027            "2": {
8028                "start": { "line": 3, "column": 14 },
8029                "end": { "line": 3, "column": 27 }
8030            },
8031            "3": {
8032                "start": { "line": 4, "column": 4 },
8033                "end": { "line": 4, "column": 16 }
8034            },
8035            "4": {
8036                "start": { "line": 8, "column": 0 },
8037                "end": { "line": 8, "column": 27 }
8038            }
8039        })
8040    }
8041
8042    /// istanbul-lib-instrument geometry for a multi-line higher-order
8043    /// component. The producer's `line` is the body start line, so the outer
8044    /// record also carries an effective alias on line 2.
8045    #[test]
8046    fn curried_arrow_multiline_hoc_resolves_both_arrows() {
8047        let temp = tempfile::TempDir::new().unwrap();
8048        let source_path = temp.path().join("src/with-auth.tsx");
8049        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8050        std::fs::write(
8051            &source_path,
8052            "export const withAuth = (Component) =>\n  (props) => {\n    return Component(props);\n  };\n",
8053        )
8054        .unwrap();
8055
8056        let coverage_path = temp.path().join("coverage-final.json");
8057        write_single_file_istanbul_fixture(
8058            &coverage_path,
8059            &source_path,
8060            &serde_json::json!({
8061                "0": {
8062                    "name": "(anonymous_0)",
8063                    "line": 2,
8064                    "decl": {
8065                        "start": { "line": 1, "column": 24 },
8066                        "end": { "line": 1, "column": 25 }
8067                    },
8068                    "loc": {
8069                        "start": { "line": 2, "column": 2 },
8070                        "end": { "line": 4, "column": 3 }
8071                    }
8072                },
8073                "1": {
8074                    "name": "(anonymous_1)",
8075                    "line": 2,
8076                    "decl": {
8077                        "start": { "line": 2, "column": 2 },
8078                        "end": { "line": 2, "column": 3 }
8079                    },
8080                    "loc": {
8081                        "start": { "line": 2, "column": 13 },
8082                        "end": { "line": 4, "column": 3 }
8083                    }
8084                }
8085            }),
8086            &serde_json::json!({ "0": 1, "1": 0 }),
8087        );
8088
8089        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8090        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8091        let file_coverage = coverage.get(&canonical_source).unwrap();
8092
8093        assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
8094        assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
8095    }
8096
8097    /// istanbul-lib-instrument geometry for a depth-3 redux middleware chain.
8098    /// Every non-first arrow is declared where the previous body starts.
8099    #[test]
8100    fn curried_arrow_depth_three_chain_resolves_every_arrow() {
8101        let temp = tempfile::TempDir::new().unwrap();
8102        let source_path = temp.path().join("src/logger.ts");
8103        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8104        std::fs::write(
8105            &source_path,
8106            "export const logger = (store) => (next) => (action) => {\n  return next(action);\n};\n",
8107        )
8108        .unwrap();
8109
8110        let coverage_path = temp.path().join("coverage-final.json");
8111        write_single_file_istanbul_fixture(
8112            &coverage_path,
8113            &source_path,
8114            &serde_json::json!({
8115                "0": {
8116                    "name": "(anonymous_0)",
8117                    "line": 1,
8118                    "decl": {
8119                        "start": { "line": 1, "column": 22 },
8120                        "end": { "line": 1, "column": 23 }
8121                    },
8122                    "loc": {
8123                        "start": { "line": 1, "column": 33 },
8124                        "end": { "line": 3, "column": 1 }
8125                    }
8126                },
8127                "1": {
8128                    "name": "(anonymous_1)",
8129                    "line": 1,
8130                    "decl": {
8131                        "start": { "line": 1, "column": 33 },
8132                        "end": { "line": 1, "column": 34 }
8133                    },
8134                    "loc": {
8135                        "start": { "line": 1, "column": 43 },
8136                        "end": { "line": 3, "column": 1 }
8137                    }
8138                },
8139                "2": {
8140                    "name": "(anonymous_2)",
8141                    "line": 1,
8142                    "decl": {
8143                        "start": { "line": 1, "column": 43 },
8144                        "end": { "line": 1, "column": 44 }
8145                    },
8146                    "loc": {
8147                        "start": { "line": 1, "column": 55 },
8148                        "end": { "line": 3, "column": 1 }
8149                    }
8150                }
8151            }),
8152            &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
8153        );
8154
8155        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8156        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8157        let file_coverage = coverage.get(&canonical_source).unwrap();
8158
8159        assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
8160        assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
8161        assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
8162    }
8163
8164    /// istanbul-lib-instrument geometry for a curried class-property arrow.
8165    #[test]
8166    fn curried_class_property_arrow_resolves_both_arrows() {
8167        let temp = tempfile::TempDir::new().unwrap();
8168        let source_path = temp.path().join("src/store.ts");
8169        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8170        std::fs::write(
8171            &source_path,
8172            "export class Store {\n  handle = (event) => (payload) => {\n    return payload;\n  };\n}\n",
8173        )
8174        .unwrap();
8175
8176        let coverage_path = temp.path().join("coverage-final.json");
8177        write_single_file_istanbul_fixture(
8178            &coverage_path,
8179            &source_path,
8180            &serde_json::json!({
8181                "0": {
8182                    "name": "(anonymous_0)",
8183                    "line": 2,
8184                    "decl": {
8185                        "start": { "line": 2, "column": 11 },
8186                        "end": { "line": 2, "column": 12 }
8187                    },
8188                    "loc": {
8189                        "start": { "line": 2, "column": 22 },
8190                        "end": { "line": 4, "column": 3 }
8191                    }
8192                },
8193                "1": {
8194                    "name": "(anonymous_1)",
8195                    "line": 2,
8196                    "decl": {
8197                        "start": { "line": 2, "column": 22 },
8198                        "end": { "line": 2, "column": 23 }
8199                    },
8200                    "loc": {
8201                        "start": { "line": 2, "column": 35 },
8202                        "end": { "line": 4, "column": 3 }
8203                    }
8204                }
8205            }),
8206            &serde_json::json!({ "0": 1, "1": 0 }),
8207        );
8208
8209        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8210        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8211        let file_coverage = coverage.get(&canonical_source).unwrap();
8212
8213        assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
8214        assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
8215    }
8216
8217    /// istanbul-lib-instrument geometry for two sibling arrows in an object
8218    /// literal. A target one line away from both, at the shared column, ties
8219    /// on distance and lies in neither body, so the lookup abstains.
8220    #[test]
8221    fn anonymous_sibling_tie_outside_every_body_abstains() {
8222        let temp = tempfile::TempDir::new().unwrap();
8223        let source_path = temp.path().join("src/handlers.ts");
8224        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8225        std::fs::write(
8226            &source_path,
8227            "export const handlers = {\n  a: () => true,\n\n  b: () => false,\n};\n",
8228        )
8229        .unwrap();
8230
8231        let coverage_path = temp.path().join("coverage-final.json");
8232        write_single_file_istanbul_fixture(
8233            &coverage_path,
8234            &source_path,
8235            &serde_json::json!({
8236                "0": {
8237                    "name": "(anonymous_0)",
8238                    "line": 2,
8239                    "decl": {
8240                        "start": { "line": 2, "column": 5 },
8241                        "end": { "line": 2, "column": 6 }
8242                    },
8243                    "loc": {
8244                        "start": { "line": 2, "column": 11 },
8245                        "end": { "line": 2, "column": 15 }
8246                    }
8247                },
8248                "1": {
8249                    "name": "(anonymous_1)",
8250                    "line": 4,
8251                    "decl": {
8252                        "start": { "line": 4, "column": 5 },
8253                        "end": { "line": 4, "column": 6 }
8254                    },
8255                    "loc": {
8256                        "start": { "line": 4, "column": 11 },
8257                        "end": { "line": 4, "column": 16 }
8258                    }
8259                }
8260            }),
8261            &serde_json::json!({ "0": 1, "1": 0 }),
8262        );
8263
8264        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8265        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8266        let file_coverage = coverage.get(&canonical_source).unwrap();
8267
8268        assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
8269        assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
8270        assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
8271    }
8272
8273    /// istanbul-lib-instrument geometry for a function expression whose block
8274    /// body wraps an arrow, with the closing braces on a second line. A target
8275    /// on that line, equidistant from the outer `{` and the inner declaration,
8276    /// ties on distance and lies inside both bodies; the strictly innermost
8277    /// body wins.
8278    #[test]
8279    fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
8280        let temp = tempfile::TempDir::new().unwrap();
8281        let source_path = temp.path().join("src/nested.ts");
8282        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8283        std::fs::write(
8284            &source_path,
8285            "export const o = function () { const f = () => { return 1;\n                                       }; return f; };\n",
8286        )
8287        .unwrap();
8288
8289        let coverage_path = temp.path().join("coverage-final.json");
8290        write_single_file_istanbul_fixture(
8291            &coverage_path,
8292            &source_path,
8293            &serde_json::json!({
8294                "0": {
8295                    "name": "(anonymous_0)",
8296                    "line": 1,
8297                    "decl": {
8298                        "start": { "line": 1, "column": 17 },
8299                        "end": { "line": 1, "column": 18 }
8300                    },
8301                    "loc": {
8302                        "start": { "line": 1, "column": 29 },
8303                        "end": { "line": 2, "column": 53 }
8304                    }
8305                },
8306                "1": {
8307                    "name": "(anonymous_1)",
8308                    "line": 1,
8309                    "decl": {
8310                        "start": { "line": 1, "column": 41 },
8311                        "end": { "line": 1, "column": 42 }
8312                    },
8313                    "loc": {
8314                        "start": { "line": 1, "column": 47 },
8315                        "end": { "line": 2, "column": 40 }
8316                    }
8317                }
8318            }),
8319            &serde_json::json!({ "0": 1, "1": 0 }),
8320        );
8321
8322        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8323        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8324        let file_coverage = coverage.get(&canonical_source).unwrap();
8325
8326        assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
8327    }
8328
8329    /// Defensive: no producer emits partially overlapping bodies, but a tie
8330    /// between two containing spans that are not nested must not pick either.
8331    #[test]
8332    fn anonymous_tie_rejects_incomparable_containing_spans() {
8333        let file_coverage = IstanbulFileCoverage::new(
8334            vec![
8335                IstanbulFunctionCoverage {
8336                    name: "(anonymous_0)".to_string(),
8337                    coverage_pct: 100.0,
8338                    aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
8339                    decl_start: IstanbulPosition::new(10, 8),
8340                    header_holds_other_fn: false,
8341                    header_span: None,
8342                    body_span: Some(body_span((10, 14), (12, 30))),
8343                },
8344                IstanbulFunctionCoverage {
8345                    name: "(anonymous_1)".to_string(),
8346                    coverage_pct: 0.0,
8347                    aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
8348                    decl_start: IstanbulPosition::new(10, 20),
8349                    header_holds_other_fn: false,
8350                    header_span: None,
8351                    body_span: Some(body_span((11, 0), (14, 0))),
8352                },
8353            ],
8354            false,
8355        );
8356
8357        assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
8358    }
8359
8360    /// Two records whose primary aliases coincide (a multi-line parameter
8361    /// list whose body-start line and declaration column produce the same
8362    /// effective position as the inner arrow's declaration) stay ambiguous
8363    /// even though their bodies nest.
8364    #[test]
8365    fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
8366        let file_coverage = IstanbulFileCoverage::new(
8367            vec![
8368                IstanbulFunctionCoverage {
8369                    name: "(anonymous_0)".to_string(),
8370                    coverage_pct: 100.0,
8371                    aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
8372                    decl_start: IstanbulPosition::new(4, 11),
8373                    header_holds_other_fn: false,
8374                    header_span: None,
8375                    body_span: Some(body_span((4, 11), (4, 23))),
8376                },
8377                IstanbulFunctionCoverage {
8378                    name: "(anonymous_1)".to_string(),
8379                    coverage_pct: 0.0,
8380                    aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
8381                    decl_start: IstanbulPosition::new(4, 11),
8382                    header_holds_other_fn: false,
8383                    header_span: None,
8384                    body_span: Some(body_span((4, 18), (4, 23))),
8385                },
8386            ],
8387            false,
8388        );
8389
8390        assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
8391        assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
8392    }
8393
8394    #[test]
8395    fn colliding_anonymous_alias_uses_unique_safe_header_span() {
8396        let file_coverage = IstanbulFileCoverage::new(
8397            vec![
8398                IstanbulFunctionCoverage {
8399                    name: "(anonymous_0)".to_string(),
8400                    coverage_pct: 100.0,
8401                    aliases: vec![primary_alias(1, 0), secondary_alias(3, 4)],
8402                    decl_start: IstanbulPosition::new(1, 0),
8403                    header_holds_other_fn: false,
8404                    header_span: Some(body_span((1, 0), (5, 0))),
8405                    body_span: Some(body_span((5, 0), (8, 0))),
8406                },
8407                IstanbulFunctionCoverage {
8408                    name: "(anonymous_1)".to_string(),
8409                    coverage_pct: 0.0,
8410                    aliases: vec![primary_alias(10, 0), secondary_alias(3, 4)],
8411                    decl_start: IstanbulPosition::new(10, 0),
8412                    header_holds_other_fn: false,
8413                    header_span: None,
8414                    body_span: Some(body_span((10, 0), (12, 0))),
8415                },
8416            ],
8417            false,
8418        );
8419
8420        assert_eq!(file_coverage.lookup("<arrow>", 3, 4), Some(100.0));
8421    }
8422
8423    /// A secondary alias that collides with another record's secondary alias
8424    /// is dropped from both, and the shared position remains ambiguous.
8425    #[test]
8426    fn colliding_secondary_aliases_abstain_at_shared_position() {
8427        let file_coverage = IstanbulFileCoverage::new(
8428            vec![
8429                IstanbulFunctionCoverage {
8430                    name: "(anonymous_0)".to_string(),
8431                    coverage_pct: 100.0,
8432                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8433                    decl_start: IstanbulPosition::new(10, 0),
8434                    header_holds_other_fn: false,
8435                    header_span: None,
8436                    body_span: Some(body_span((12, 4), (20, 0))),
8437                },
8438                IstanbulFunctionCoverage {
8439                    name: "(anonymous_1)".to_string(),
8440                    coverage_pct: 0.0,
8441                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8442                    decl_start: IstanbulPosition::new(11, 0),
8443                    header_holds_other_fn: false,
8444                    header_span: None,
8445                    body_span: Some(body_span((12, 4), (18, 0))),
8446                },
8447            ],
8448            false,
8449        );
8450
8451        assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
8452        assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
8453        assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
8454    }
8455
8456    #[test]
8457    fn colliding_named_secondary_aliases_abstain_at_shared_position() {
8458        let file_coverage = IstanbulFileCoverage::new(
8459            vec![
8460                IstanbulFunctionCoverage {
8461                    name: "handler".to_string(),
8462                    coverage_pct: 100.0,
8463                    aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8464                    decl_start: IstanbulPosition::new(10, 0),
8465                    header_holds_other_fn: false,
8466                    header_span: None,
8467                    body_span: Some(body_span((12, 4), (20, 0))),
8468                },
8469                IstanbulFunctionCoverage {
8470                    name: "handler".to_string(),
8471                    coverage_pct: 0.0,
8472                    aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8473                    decl_start: IstanbulPosition::new(11, 0),
8474                    header_holds_other_fn: false,
8475                    header_span: None,
8476                    body_span: Some(body_span((12, 4), (18, 0))),
8477                },
8478            ],
8479            false,
8480        );
8481
8482        assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
8483        assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
8484        assert!(file_coverage.lookup("handler", 12, 4).is_none());
8485    }
8486
8487    #[test]
8488    fn invalid_body_location_does_not_create_an_alias() {
8489        let temp = tempfile::TempDir::new().unwrap();
8490        let source_path = temp.path().join("src/invalid-location.ts");
8491        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8492        std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
8493
8494        let coverage_path = temp.path().join("coverage-final.json");
8495        write_single_file_istanbul_fixture(
8496            &coverage_path,
8497            &source_path,
8498            &serde_json::json!({
8499                "0": {
8500                    "name": "(anonymous_0)",
8501                    "line": 8,
8502                    "decl": {
8503                        "start": { "line": 8, "column": 14 },
8504                        "end": { "line": 8, "column": 25 }
8505                    },
8506                    "loc": {
8507                        "start": { "line": 22, "column": 1 },
8508                        "end": { "line": 20, "column": 6 }
8509                    }
8510                }
8511            }),
8512            &serde_json::json!({ "0": 1 }),
8513        );
8514
8515        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8516        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8517        let file_coverage = coverage.get(&canonical_source).unwrap();
8518
8519        assert!(file_coverage.lookup("handler", 22, 1).is_none());
8520    }
8521
8522    #[test]
8523    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
8524        let temp = tempfile::TempDir::new().unwrap();
8525        let source_path = temp.path().join("src/actor.ts");
8526        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8527        std::fs::write(
8528            &source_path,
8529            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
8530        )
8531        .unwrap();
8532
8533        let coverage_path = temp.path().join("coverage-final.json");
8534        write_single_file_istanbul_fixture(
8535            &coverage_path,
8536            &source_path,
8537            &serde_json::json!({
8538                "0": {
8539                    "name": "(anonymous_0)",
8540                    "line": 4,
8541                    "decl": {
8542                        "start": { "line": 1, "column": 28 },
8543                        "end": { "line": 4, "column": 26 }
8544                    },
8545                    "loc": {
8546                        "start": { "line": 4, "column": 27 },
8547                        "end": { "line": 6, "column": 1 }
8548                    }
8549                }
8550            }),
8551            &serde_json::json!({
8552                "0": 642
8553            }),
8554        );
8555
8556        let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8557        let canonical_source = dunce::canonicalize(&source_path).unwrap();
8558        let file_coverage = coverage.get(&canonical_source).unwrap();
8559
8560        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
8561    }
8562
8563    #[test]
8564    fn istanbul_lookup_exact_match() {
8565        let mut functions = rustc_hash::FxHashMap::default();
8566        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8567        let fc = test_istanbul_file_coverage(functions, false);
8568        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
8569    }
8570
8571    #[test]
8572    fn istanbul_lookup_fuzzy_match_within_offset() {
8573        let mut functions = rustc_hash::FxHashMap::default();
8574        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8575        let fc = test_istanbul_file_coverage(functions, false);
8576        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8577        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8578    }
8579
8580    #[test]
8581    fn istanbul_lookup_fuzzy_match_outside_offset() {
8582        let mut functions = rustc_hash::FxHashMap::default();
8583        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8584        let fc = test_istanbul_file_coverage(functions, false);
8585        assert!(fc.lookup("handleClick", 13, 0).is_none());
8586    }
8587
8588    #[test]
8589    fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
8590        let mut functions = rustc_hash::FxHashMap::default();
8591        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8592        let fc = test_istanbul_file_coverage(functions, true);
8593        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8594    }
8595
8596    #[test]
8597    fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
8598        let mut functions = rustc_hash::FxHashMap::default();
8599        functions.insert(("handleClick".to_string(), 29, 16), 72.0);
8600        functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8601        let fc = test_istanbul_file_coverage(functions, true);
8602        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8603    }
8604
8605    #[test]
8606    fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
8607        let mut functions = rustc_hash::FxHashMap::default();
8608        functions.insert(("render".to_string(), 29, 0), 72.0);
8609        functions.insert(("render".to_string(), 80, 0), 10.0);
8610        let fc = test_istanbul_file_coverage(functions, true);
8611        assert!(fc.lookup("render", 10, 0).is_none());
8612    }
8613
8614    #[test]
8615    fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
8616        let mut functions = rustc_hash::FxHashMap::default();
8617        functions.insert(("render".to_string(), 11, 0), 72.0);
8618        functions.insert(("render".to_string(), 80, 0), 10.0);
8619        let fc = test_istanbul_file_coverage(functions, true);
8620        assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8621    }
8622
8623    #[test]
8624    fn istanbul_lookup_name_mismatch() {
8625        let mut functions = rustc_hash::FxHashMap::default();
8626        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8627        let fc = test_istanbul_file_coverage(functions, false);
8628        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
8629    }
8630
8631    #[test]
8632    fn istanbul_lookup_empty() {
8633        let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
8634        assert!(fc.lookup("anything", 1, 0).is_none());
8635    }
8636
8637    #[test]
8638    fn istanbul_lookup_fuzzy_picks_closest() {
8639        let mut functions = rustc_hash::FxHashMap::default();
8640        functions.insert(("render".to_string(), 8, 0), 60.0);
8641        functions.insert(("render".to_string(), 12, 0), 90.0);
8642        let fc = test_istanbul_file_coverage(functions, false);
8643        let result = fc.lookup("render", 10, 0);
8644        assert!(result.is_some());
8645        let pct = result.unwrap();
8646        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
8647    }
8648
8649    #[test]
8650    fn istanbul_lookup_anonymous_fallback_single_candidate() {
8651        let mut functions = rustc_hash::FxHashMap::default();
8652        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8653        let fc = test_istanbul_file_coverage(functions, false);
8654        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8655        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8656    }
8657
8658    #[test]
8659    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
8660        let mut functions = rustc_hash::FxHashMap::default();
8661        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
8662        let fc = test_istanbul_file_coverage(functions, false);
8663
8664        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
8665    }
8666
8667    #[test]
8668    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
8669        let mut functions = rustc_hash::FxHashMap::default();
8670        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8671        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8672        let fc = test_istanbul_file_coverage(functions, false);
8673        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8674    }
8675
8676    #[test]
8677    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
8678        let mut functions = rustc_hash::FxHashMap::default();
8679        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
8680        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
8681        let fc = test_istanbul_file_coverage(functions, false);
8682        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
8683        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
8684    }
8685
8686    #[test]
8687    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
8688        let mut functions = rustc_hash::FxHashMap::default();
8689        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
8690        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8691        let fc = test_istanbul_file_coverage(functions, false);
8692        assert!(fc.lookup("myHandler", 28, 0).is_none());
8693    }
8694
8695    #[test]
8696    fn istanbul_lookup_anonymous_fallback_outside_offset() {
8697        let mut functions = rustc_hash::FxHashMap::default();
8698        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8699        let fc = test_istanbul_file_coverage(functions, false);
8700        assert!(fc.lookup("myHandler", 31, 0).is_none());
8701    }
8702
8703    #[test]
8704    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
8705        let mut functions = rustc_hash::FxHashMap::default();
8706        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
8707        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
8708        let fc = test_istanbul_file_coverage(functions, false);
8709        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
8710    }
8711
8712    #[test]
8713    fn build_test_refs_empty() {
8714        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
8715        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
8716        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
8717        assert!(refs.is_empty());
8718    }
8719
8720    #[test]
8721    fn istanbul_crap_empty_complexity() {
8722        let result = istanbul_crap_default(&[], None, false);
8723        assert!((result.max_crap).abs() < f64::EPSILON);
8724        assert_eq!(result.signals.above, 0);
8725        assert_eq!(result.matched, 0);
8726        assert_eq!(result.total, 0);
8727    }
8728
8729    #[test]
8730    fn istanbul_crap_match_statistics() {
8731        let funcs = vec![make_fn_complexity(5), {
8732            let mut f = make_fn_complexity(3);
8733            f.name = "other_fn".into();
8734            f.line = 10;
8735            f
8736        }];
8737        let mut functions = rustc_hash::FxHashMap::default();
8738        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
8739        let file_cov = test_istanbul_file_coverage(functions, false);
8740        let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
8741        assert_eq!(result.matched, 1);
8742        assert_eq!(result.total, 2);
8743    }
8744
8745    #[test]
8746    fn estimated_crap_multiple_functions_mixed_coverage() {
8747        let funcs = vec![
8748            make_fn_complexity(10), // name "test_fn" line 1
8749            {
8750                let mut f = make_fn_complexity(3);
8751                f.name = "helper".into();
8752                f.line = 20;
8753                f
8754            },
8755        ];
8756        let mut refs = rustc_hash::FxHashSet::default();
8757        refs.insert("test_fn".to_string());
8758        let result = estimated_crap_default(
8759            &funcs,
8760            &refs,
8761            true,
8762            fallow_output::CoverageSource::Estimated,
8763        );
8764        let (max, above) = (result.max_crap, result.signals.above);
8765        assert!(max > 10.0);
8766        assert_eq!(above, 0);
8767    }
8768}