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