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