Skip to main content

fallow_engine/health/
scoring.rs

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