Skip to main content

fallow_engine/health/
scoring.rs

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