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};
7
8/// Output from `compute_file_scores`, including auxiliary data for refactoring targets.
9pub struct FileScoreOutput {
10    pub(crate) scores: Vec<FileHealthScore>,
11    /// Static coverage gaps derived from runtime-vs-test reachability.
12    pub(crate) coverage: CoverageGapData,
13    /// Files participating in circular dependencies (absolute paths).
14    pub(crate) circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
15    /// Top 3 functions by cognitive complexity per file (name, line, cognitive score).
16    pub(crate) top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
17    /// Files that are configured entry points.
18    pub(crate) entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
19    /// Total number of value exports per file (for dead code gate: total_value_exports >= 3).
20    pub(crate) value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
21    /// Unused export names per file (for evidence linking).
22    pub(crate) unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
23    /// Cycle members per file: maps each file to the other files in its cycle.
24    pub(crate) cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
25    /// Direct importers per file, with the symbols imported by each caller.
26    pub(crate) direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
27    /// Aggregate counts from AnalysisResults for vital signs (project-wide).
28    pub(crate) analysis_counts: crate::vital_signs::AnalysisCounts,
29    /// Located prop-drilling chains from the analysis results (empty when the
30    /// opt-in `prop-drilling` rule is off, since the detector populates no chains
31    /// then). Drives the small capped health penalty, the hotspot surface, and
32    /// the `health --format json` `prop_drilling_chains` array.
33    pub(crate) prop_drilling_chains: Vec<fallow_types::output_dead_code::PropDrillingChainFinding>,
34    /// Per-component render fan-in (JSX render SITES + distinct parents) plus the
35    /// precomputed concentration aggregates, cloned from the analysis results.
36    /// `None` on non-React projects. Descriptive blast-radius signal: feeds the
37    /// `VitalSigns` render-fan-in aggregates and the hotspot/react drill-down
38    /// `rendered in N places` line (keyed back to file paths).
39    pub(crate) render_fan_in: Option<fallow_types::results::RenderFanInMetric>,
40    /// Per-path snapshot of analysis findings, used to recompute
41    /// [`crate::vital_signs::AnalysisCounts`] for an arbitrary subset of files
42    /// (workspace scoping, `--group-by` partitioning).
43    pub(crate) analysis_snapshot: AnalysisCountsSnapshot,
44    /// Istanbul match stats: functions matched / total (only meaningful with Istanbul model).
45    pub(crate) istanbul_matched: usize,
46    pub(crate) istanbul_total: usize,
47    /// Per-file, per-function CRAP data used to emit `--max-crap` findings.
48    /// Absolute paths match `FileHealthScore.path`. Absent entries indicate the
49    /// file had zero functions.
50    pub(crate) per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
51    /// Provenance map for synthetic Angular `<template>` findings whose CRAP
52    /// was inherited from the owning `.component.ts` via the inverse
53    /// `templateUrl` edge. Keys are the template `.html` absolute paths,
54    /// values are the owner `.ts` absolute paths (the path used for the
55    /// `inherited from foo.component.ts` human-output suffix). Absent for
56    /// non-template files and for templates with no `.ts` owner.
57    pub(crate) template_inherit_provenance:
58        rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
59}
60
61struct FileScoreOutputParts<'a> {
62    graph: &'a fallow_graph::graph::ModuleGraph,
63    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
64    results: &'a crate::results::AnalysisResults,
65    scores: Vec<FileHealthScore>,
66    coverage: CoverageGapData,
67    circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
68    top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
69    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
70    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
71    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
72    cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
73    direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
74    istanbul_matched: usize,
75    istanbul_total: usize,
76    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
77    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
78}
79
80/// Per-path snapshot of analysis-pipeline findings, retained alongside the
81/// pre-aggregated `analysis_counts` so that workspace- or group-scoped runs
82/// can recompute counts without re-running the full pipeline.
83///
84/// All paths are absolute (matching `AnalysisResults` and `FileHealthScore`).
85#[derive(Clone, Default)]
86pub struct AnalysisCountsSnapshot {
87    /// One entry per unused file.
88    unused_file_paths: Vec<std::path::PathBuf>,
89    /// One entry per unused value or type export, keyed by the file containing
90    /// the export.
91    unused_export_paths: Vec<std::path::PathBuf>,
92    /// One entry per unused dependency across `dependencies`,
93    /// `devDependencies`, and `optionalDependencies`, keyed by the
94    /// `package.json` path that declared it.
95    unused_dep_package_paths: Vec<std::path::PathBuf>,
96    /// Each cycle as the set of file paths it contains. Used to count cycles
97    /// that touch any file inside a workspace.
98    circular_dep_groups: Vec<Vec<std::path::PathBuf>>,
99    /// Total exports per module (`module.exports.len()` in the graph), used
100    /// as the denominator for `dead_export_pct`.
101    module_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
102}
103
104impl AnalysisCountsSnapshot {
105    /// Compute analysis counts for the file subset selected by `subset`.
106    ///
107    /// Returns `*defaults` when `subset.is_full()`. Otherwise recomputes
108    /// every count by retaining paths the subset accepts. Cycles are counted
109    /// when any cycle member is in the subset.
110    ///
111    /// Unused-dep counting is special-cased: dep entries are keyed by their
112    /// `package.json` path, which is never a source file and therefore never
113    /// matches the source-file membership of a `Paths` subset. For
114    /// `SubsetFilter::Paths`, a `package.json` is considered
115    /// in scope when at least one source file in the subset sits inside its
116    /// directory (the dep's owning workspace).
117    ///
118    /// `total_deps` is propagated unchanged from `defaults`; it is not
119    /// available per-subset today (mirrors the project-wide behaviour).
120    pub(crate) fn counts_for(
121        &self,
122        subset: &crate::health::SubsetFilter<'_>,
123        defaults: &crate::vital_signs::AnalysisCounts,
124    ) -> crate::vital_signs::AnalysisCounts {
125        if subset.is_full() {
126            return *defaults;
127        }
128        let dead_files = self
129            .unused_file_paths
130            .iter()
131            .filter(|p| subset.matches(p))
132            .count();
133        let dead_exports = self
134            .unused_export_paths
135            .iter()
136            .filter(|p| subset.matches(p))
137            .count();
138        let unused_deps = self
139            .unused_dep_package_paths
140            .iter()
141            .filter(|dep_path| dep_in_subset(subset, dep_path))
142            .count();
143        let circular_deps = self
144            .circular_dep_groups
145            .iter()
146            .filter(|cycle| cycle.iter().any(|p| subset.matches(p)))
147            .count();
148        let total_exports = self
149            .module_export_counts
150            .iter()
151            .filter(|(p, _)| subset.matches(p))
152            .map(|(_, n)| *n)
153            .sum();
154        crate::vital_signs::AnalysisCounts {
155            total_exports,
156            dead_files,
157            dead_exports,
158            unused_deps,
159            circular_deps,
160            total_deps: defaults.total_deps,
161        }
162    }
163}
164
165/// Return true when an unused dependency's `package.json` path belongs to
166/// the subset.
167///
168/// For [`crate::health::SubsetFilter::Paths`] the dep's containing workspace
169/// (its `package.json` parent directory) is considered in scope when at
170/// least one source file in the subset lives under that directory.
171fn dep_in_subset(subset: &crate::health::SubsetFilter<'_>, dep_path: &std::path::Path) -> bool {
172    match subset {
173        crate::health::SubsetFilter::Full => true,
174        crate::health::SubsetFilter::Paths(set) => {
175            let Some(workspace_root) = dep_path.parent() else {
176                return false;
177            };
178            set.iter().any(|p| p.starts_with(workspace_root))
179        }
180    }
181}
182
183/// Aggregate complexity totals from a parsed module.
184///
185/// Returns `(total_cyclomatic, total_cognitive, function_count, lines)`.
186#[expect(
187    clippy::cast_possible_truncation,
188    reason = "line count is bounded by source file size"
189)]
190fn aggregate_complexity(module: &crate::source::ModuleInfo) -> (u32, u32, usize, u32) {
191    let cyc: u32 = module
192        .complexity
193        .iter()
194        .map(|f| u32::from(f.cyclomatic))
195        .sum();
196    let cog: u32 = module
197        .complexity
198        .iter()
199        .map(|f| u32::from(f.cognitive))
200        .sum();
201    let funcs = module.complexity.len();
202    let lines = module.line_offsets.len() as u32;
203    (cyc, cog, funcs, lines)
204}
205
206/// Compute the dead code ratio for a single file.
207///
208/// Returns the fraction of VALUE exports with zero references (0.0-1.0).
209/// Type-only exports (interfaces, type aliases) are excluded from both
210/// numerator and denominator to avoid inflating the ratio for well-typed
211/// codebases. Returns 1.0 if the entire file is unused, 0.0 if it has no
212/// value exports.
213fn compute_dead_code_ratio(
214    path: &std::path::Path,
215    exports: &[fallow_graph::graph::ExportSymbol],
216    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
217    unused_exports_by_path: &rustc_hash::FxHashMap<&std::path::Path, usize>,
218) -> f64 {
219    if unused_files.contains(path) {
220        return 1.0;
221    }
222    let value_exports = exports.iter().filter(|e| !e.is_type_only).count();
223    if value_exports == 0 {
224        return 0.0;
225    }
226    let unused = unused_exports_by_path.get(path).copied().unwrap_or(0);
227    (unused as f64 / value_exports as f64).min(1.0)
228}
229
230/// Compute complexity density: total cyclomatic / lines of code.
231///
232/// Returns 0.0 when the file has no lines.
233fn compute_complexity_density(total_cyclomatic: u32, lines: u32) -> f64 {
234    if lines > 0 {
235        f64::from(total_cyclomatic) / f64::from(lines)
236    } else {
237        0.0
238    }
239}
240
241/// CRAP score threshold (inclusive). CC=5 untested gives exactly 30 (5^2 + 5),
242/// matching the canonical CRAP threshold from Savoia & Evans (2007).
243pub(super) const CRAP_THRESHOLD: f64 = 30.0;
244
245/// Compute per-function CRAP scores using the static binary model.
246///
247/// Binary model: test-reachable file -> CRAP = CC, untested -> CRAP = CC^2 + CC.
248/// Superseded by `compute_crap_scores_estimated` but retained for test coverage
249/// of the binary formula behavior.
250///
251/// Returns `(max_crap, count_above_threshold)`.
252#[cfg(test)]
253#[expect(
254    clippy::suboptimal_flops,
255    reason = "cc * cc + cc matches the CRAP formula specification"
256)]
257fn compute_crap_scores_binary(
258    complexity: &[fallow_types::extract::FunctionComplexity],
259    is_test_reachable: bool,
260) -> (f64, usize) {
261    if complexity.is_empty() {
262        return (0.0, 0);
263    }
264    let mut max = 0.0_f64;
265    let mut above = 0usize;
266    for f in complexity {
267        let cc = f64::from(f.cyclomatic);
268        let crap = if is_test_reachable { cc } else { cc * cc + cc };
269        max = max.max(crap);
270        if crap >= CRAP_THRESHOLD {
271            above += 1;
272        }
273    }
274    ((max * 10.0).round() / 10.0, above)
275}
276
277/// Per-function CRAP data used to emit `--max-crap` findings.
278#[derive(Debug, Clone, Copy)]
279pub struct PerFunctionCrap {
280    /// 1-based line number of the function's definition.
281    pub(crate) line: u32,
282    /// 0-based column of the function's definition. Required alongside `line`
283    /// to disambiguate curried arrows that share a start line, e.g.
284    /// `(x) => (y) => {...}`. Without `col`, two `PerFunctionCrap` entries
285    /// would collide in the (path, line) finding index and one function's
286    /// CRAP score could be attached to another function's identity.
287    pub(crate) col: u32,
288    /// Computed CRAP score, rounded to one decimal place.
289    pub(crate) crap: f64,
290    /// Coverage percentage used to compute `crap`, when Istanbul matched the
291    /// function. `None` for estimated coverage or unmatched functions.
292    pub(crate) coverage_pct: Option<f64>,
293    /// Bucketed coverage tier used to drive action selection in JSON output.
294    /// Populated for both Istanbul-matched and estimated CRAP rows so the
295    /// action builder does not need to recompute reachability state.
296    pub(crate) coverage_tier: fallow_output::CoverageTier,
297    /// Provenance of `coverage_tier` and `crap`. `Istanbul` for direct fnMap
298    /// matches, `Estimated` for graph-based fallbacks against the finding's
299    /// own file, `EstimatedComponentInherited` for the template-inherit path
300    /// that reaches the owning Angular `.component.ts` through the inverse
301    /// `templateUrl` edge. Threaded into `ComplexityViolation.coverage_source` by
302    /// `merge_crap_findings`.
303    pub(crate) coverage_source: fallow_output::CoverageSource,
304}
305
306/// Istanbul CRAP result: CRAP scores plus match statistics.
307struct IstanbulCrapResult {
308    pub max_crap: f64,
309    pub above_threshold: usize,
310    /// Functions that found a match in Istanbul data.
311    pub matched: usize,
312    /// Total functions evaluated.
313    pub total: usize,
314    /// Per-function CRAP data indexed by function position within `complexity`.
315    pub per_function: Vec<PerFunctionCrap>,
316}
317
318/// Compute per-function CRAP scores using Istanbul coverage data.
319///
320/// For each function, looks up its per-function statement coverage percentage
321/// from the Istanbul data and applies the canonical CRAP formula:
322/// `CRAP = CC^2 * (1 - cov/100)^3 + CC`
323///
324/// Functions not found in the coverage data fall back to the estimated model
325/// using the file's test-reachability status.
326///
327/// Returns CRAP scores and match statistics for reporting.
328fn compute_crap_scores_istanbul(
329    complexity: &[fallow_types::extract::FunctionComplexity],
330    file_coverage: Option<&IstanbulFileCoverage>,
331    is_test_reachable: bool,
332) -> IstanbulCrapResult {
333    if complexity.is_empty() {
334        return IstanbulCrapResult {
335            max_crap: 0.0,
336            above_threshold: 0,
337            matched: 0,
338            total: 0,
339            per_function: Vec::new(),
340        };
341    }
342    let mut max = 0.0_f64;
343    let mut above = 0usize;
344    let mut matched = 0usize;
345    let mut per_function = Vec::with_capacity(complexity.len());
346    for f in complexity {
347        let (crap, coverage_pct, tier, source) =
348            crap_for_function(f, file_coverage, is_test_reachable, &mut matched);
349        let crap_rounded = (crap * 10.0).round() / 10.0;
350        max = max.max(crap);
351        if crap >= CRAP_THRESHOLD {
352            above += 1;
353        }
354        per_function.push(PerFunctionCrap {
355            line: f.line,
356            col: f.col,
357            crap: crap_rounded,
358            coverage_pct,
359            coverage_tier: tier,
360            coverage_source: source,
361        });
362    }
363    IstanbulCrapResult {
364        max_crap: (max * 10.0).round() / 10.0,
365        above_threshold: above,
366        matched,
367        total: complexity.len(),
368        per_function,
369    }
370}
371
372/// Resolve one function's `(crap, coverage_pct, tier, source)` from Istanbul
373/// coverage, falling back to the test-reachability estimate model. Increments
374/// `matched` when a real coverage value is found.
375#[expect(
376    clippy::suboptimal_flops,
377    reason = "cc * cc + cc matches the CRAP formula specification"
378)]
379fn crap_for_function(
380    f: &fallow_types::extract::FunctionComplexity,
381    file_coverage: Option<&IstanbulFileCoverage>,
382    is_test_reachable: bool,
383    matched: &mut usize,
384) -> (
385    f64,
386    Option<f64>,
387    fallow_output::CoverageTier,
388    fallow_output::CoverageSource,
389) {
390    let cc = f64::from(f.cyclomatic);
391    let lookup = file_coverage.and_then(|fc| fc.lookup(f.name.as_str(), f.line, f.col));
392    if let Some(cov_pct) = lookup {
393        *matched += 1;
394        return (
395            crap_formula(cc, cov_pct),
396            Some(cov_pct),
397            fallow_output::CoverageTier::from_pct(cov_pct),
398            fallow_output::CoverageSource::Istanbul,
399        );
400    }
401    if is_test_reachable {
402        return (
403            cc,
404            None,
405            fallow_output::CoverageTier::from_pct(INDIRECT_TEST_COVERAGE_ESTIMATE),
406            fallow_output::CoverageSource::Estimated,
407        );
408    }
409    (
410        cc * cc + cc,
411        None,
412        fallow_output::CoverageTier::None,
413        fallow_output::CoverageSource::Estimated,
414    )
415}
416
417/// Estimated coverage for functions directly referenced by test-reachable modules.
418/// An export imported in a test file likely exercises most of the function body.
419const DIRECT_TEST_COVERAGE_ESTIMATE: f64 = 85.0;
420
421/// Estimated coverage for functions in test-reachable files but not directly
422/// referenced by tests. The file is imported by tests, so the function may
423/// be exercised indirectly, but with lower confidence.
424const INDIRECT_TEST_COVERAGE_ESTIMATE: f64 = 40.0;
425const MAX_DIRECT_CALLER_EVIDENCE: usize = 5;
426
427/// Compute per-function CRAP scores using graph-based coverage estimation.
428///
429/// For each function, estimates coverage from the module graph:
430/// - Function name matches an export with test-reachable references: 85%
431/// - File is test-reachable but function not directly referenced: 40%
432/// - File is not test-reachable at all: 0%
433///
434/// Applies the canonical CRAP formula with these estimates.
435/// Returns `(max_crap, count_above_threshold)`.
436/// Estimated CRAP result: score aggregates plus per-function data.
437struct EstimatedCrapResult {
438    pub max_crap: f64,
439    pub above_threshold: usize,
440    pub per_function: Vec<PerFunctionCrap>,
441}
442
443fn compute_crap_scores_estimated(
444    complexity: &[fallow_types::extract::FunctionComplexity],
445    test_referenced_exports: &rustc_hash::FxHashSet<String>,
446    is_test_reachable: bool,
447    coverage_source: fallow_output::CoverageSource,
448) -> EstimatedCrapResult {
449    if complexity.is_empty() {
450        return EstimatedCrapResult {
451            max_crap: 0.0,
452            above_threshold: 0,
453            per_function: Vec::new(),
454        };
455    }
456    let mut max = 0.0_f64;
457    let mut above = 0usize;
458    let mut per_function = Vec::with_capacity(complexity.len());
459    for f in complexity {
460        let cc = f64::from(f.cyclomatic);
461        let estimated_coverage = if test_referenced_exports.contains(f.name.as_str()) {
462            DIRECT_TEST_COVERAGE_ESTIMATE
463        } else if is_test_reachable {
464            INDIRECT_TEST_COVERAGE_ESTIMATE
465        } else {
466            0.0
467        };
468        let crap = crap_formula(cc, estimated_coverage);
469        let crap_rounded = (crap * 10.0).round() / 10.0;
470        max = max.max(crap);
471        if crap >= CRAP_THRESHOLD {
472            above += 1;
473        }
474        per_function.push(PerFunctionCrap {
475            line: f.line,
476            col: f.col,
477            crap: crap_rounded,
478            coverage_pct: None,
479            coverage_tier: fallow_output::CoverageTier::from_pct(estimated_coverage),
480            coverage_source,
481        });
482    }
483    EstimatedCrapResult {
484        max_crap: (max * 10.0).round() / 10.0,
485        above_threshold: above,
486        per_function,
487    }
488}
489
490/// Inherited CRAP context for a synthetic `<template>` finding on an Angular
491/// `.html` template. Populated by `build_template_inherit_contexts` for every
492/// `.html` module that has a `<template>` `FunctionComplexity` entry AND is
493/// reached by at least one non-test `.ts` importer via the `templateUrl`
494/// `SideEffect` edge.
495///
496/// The reachability bit is the OR across all non-test `.ts` owners (any
497/// tested owner makes the template tested); the `test_referenced_exports`
498/// set is the union of each owner's directly-test-referenced export names;
499/// the provenance path points at the chosen owner for human output. When
500/// multiple owners exist, prefer the first test-reachable one so the
501/// "inherited from" suffix points at a meaningful owner rather than an
502/// arbitrary first match.
503#[derive(Debug, Clone)]
504pub(super) struct TemplateInheritContext {
505    pub is_test_reachable: bool,
506    pub test_referenced_exports: rustc_hash::FxHashSet<String>,
507    /// The owning `.ts` file path used for human-output provenance
508    /// (`coverage: partial (inherited from foo.component.ts)`). Set to the
509    /// first test-reachable owner when one exists, otherwise the first
510    /// non-test owner. Absolute path; the human formatter strips it.
511    pub provenance_owner: std::path::PathBuf,
512}
513
514/// Build the inverse `templateUrl` redirect map: for every `.html` module
515/// carrying a synthetic `<template>` `FunctionComplexity` entry, walk
516/// `reverse_deps` to find every `.ts` (or `.component.ts`) importer that is
517/// NOT a test entry point, and compute an aggregate `TemplateInheritContext`
518/// that the CRAP scoring loop can use to redirect reachability + test refs
519/// to the owning component file.
520///
521/// Test-file owners are excluded because Angular spec files do not declare
522/// `templateUrl`; if a `.spec.ts` is the only importer of a `.html`, the
523/// template is genuinely orphaned and the existing fallback (estimated
524/// against the `.html`'s own reachability) is the right answer.
525///
526/// The `.ts` / `.tsx` / `.mts` / `.cts` extension gate intentionally lets
527/// `.d.ts` ambient declarations through, but Angular component classes are
528/// not emitted into `.d.ts` files (which model APIs, not runtime behaviour)
529/// and `templateUrl` SideEffect edges flow only from concrete `@Component`
530/// decorators. A `.d.ts` importer of a `.html` would be a structural
531/// anomaly upstream, not a meaningful owner, so the gate stays simple.
532///
533/// Templates with zero non-test `.ts` owners receive no entry, so the
534/// scoring loop falls through to the existing path unchanged.
535fn build_template_inherit_contexts(
536    graph: &fallow_graph::graph::ModuleGraph,
537    test_coverage: StaticTestCoverage<'_>,
538    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
539    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
540) -> rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext> {
541    let mut out = rustc_hash::FxHashMap::default();
542    for node in &graph.modules {
543        if let Some(context) =
544            template_inherit_context_for_node(node, graph, test_coverage, module_by_id, file_paths)
545        {
546            out.insert(node.file_id, context);
547        }
548    }
549    out
550}
551
552fn template_inherit_context_for_node(
553    node: &fallow_graph::graph::ModuleNode,
554    graph: &fallow_graph::graph::ModuleGraph,
555    test_coverage: StaticTestCoverage<'_>,
556    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
557    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
558) -> Option<TemplateInheritContext> {
559    if !is_template_inherit_candidate(node, module_by_id, file_paths) {
560        return None;
561    }
562    let importers = graph.reverse_deps.get(node.file_id.0 as usize)?;
563    template_inherit_context_from_importers(
564        importers,
565        graph,
566        test_coverage,
567        module_by_id,
568        file_paths,
569    )
570}
571
572fn is_template_inherit_candidate(
573    node: &fallow_graph::graph::ModuleNode,
574    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
575    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
576) -> bool {
577    let Some(path) = file_paths.get(&node.file_id) else {
578        return false;
579    };
580    if !path
581        .extension()
582        .and_then(|ext| ext.to_str())
583        .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
584    {
585        return false;
586    }
587    module_by_id.get(&node.file_id).is_some_and(|module| {
588        module
589            .complexity
590            .iter()
591            .any(|finding| finding.name.as_str() == "<template>")
592    })
593}
594
595fn template_inherit_context_from_importers(
596    importers: &[crate::discover::FileId],
597    graph: &fallow_graph::graph::ModuleGraph,
598    test_coverage: StaticTestCoverage<'_>,
599    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
600    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
601) -> Option<TemplateInheritContext> {
602    let mut any_reachable = false;
603    let mut combined_refs = rustc_hash::FxHashSet::default();
604    let mut provenance: Option<std::path::PathBuf> = None;
605    let mut first_owner: Option<std::path::PathBuf> = None;
606
607    for &importer_id in importers {
608        let Some((owner_node, owner_path)) =
609            template_owner(importer_id, graph, module_by_id, file_paths)
610        else {
611            continue;
612        };
613        if first_owner.is_none() {
614            first_owner = Some((*owner_path).clone());
615        }
616        if test_coverage.covers_file(owner_node.file_id) {
617            any_reachable = true;
618            provenance.get_or_insert_with(|| (*owner_path).clone());
619            let refs = build_test_referenced_exports(&owner_node.exports, test_coverage);
620            combined_refs.extend(refs);
621        }
622    }
623
624    let provenance_owner = provenance.or(first_owner)?;
625    Some(TemplateInheritContext {
626        is_test_reachable: any_reachable,
627        test_referenced_exports: combined_refs,
628        provenance_owner,
629    })
630}
631
632fn template_owner<'a>(
633    importer_id: crate::discover::FileId,
634    graph: &'a fallow_graph::graph::ModuleGraph,
635    module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
636    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
637) -> Option<(&'a fallow_graph::graph::ModuleNode, &'a std::path::PathBuf)> {
638    let owner_node = graph.modules.get(importer_id.0 as usize)?;
639    let owner_path = *file_paths.get(&importer_id)?;
640    if !is_template_owner_path(owner_path) || graph.test_entry_points.contains(&importer_id) {
641        return None;
642    }
643    let owner_has_component = module_by_id
644        .get(&importer_id)
645        .is_some_and(|module| module.has_angular_component_template_url);
646    owner_has_component.then_some((owner_node, owner_path))
647}
648
649fn is_template_owner_path(path: &std::path::Path) -> bool {
650    path.extension()
651        .and_then(|ext| ext.to_str())
652        .is_some_and(|ext| {
653            matches!(
654                ext.to_ascii_lowercase().as_str(),
655                "ts" | "tsx" | "mts" | "cts"
656            )
657        })
658}
659
660/// Build the set of export names that have at least one test-reachable reference.
661///
662/// This is the per-function signal: if an export named "foo" has a reference from
663/// a test-reachable module, the function "foo" is considered directly tested.
664fn build_test_referenced_exports(
665    exports: &[fallow_graph::graph::ExportSymbol],
666    test_coverage: StaticTestCoverage<'_>,
667) -> rustc_hash::FxHashSet<String> {
668    let mut set = rustc_hash::FxHashSet::default();
669    for export in exports {
670        if export.is_type_only {
671            continue;
672        }
673        let has_test_ref = export
674            .references
675            .iter()
676            .any(|reference| test_coverage.covers_reference(reference));
677        if has_test_ref {
678            set.insert(export.name.to_string());
679        }
680    }
681    set
682}
683
684fn collect_direct_callers(
685    graph: &fallow_graph::graph::ModuleGraph,
686    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
687) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>> {
688    let mut callers_by_target = rustc_hash::FxHashMap::default();
689    for node in &graph.modules {
690        let Some(target_path) = file_paths.get(&node.file_id) else {
691            continue;
692        };
693        let mut callers = graph
694            .direct_importer_summaries(node.file_id)
695            .into_iter()
696            .filter_map(|summary| {
697                file_paths
698                    .get(&summary.source)
699                    .map(|caller_path| DirectCallerEvidence {
700                        path: (*caller_path).clone(),
701                        symbols: summary
702                            .symbols
703                            .into_iter()
704                            .map(|symbol| DirectCallerSymbolEvidence {
705                                imported: symbol.imported,
706                                local: symbol.local,
707                                type_only: symbol.type_only,
708                            })
709                            .collect(),
710                    })
711            })
712            .collect::<Vec<_>>();
713        callers.sort_by(|a, b| a.path.cmp(&b.path));
714        callers.truncate(MAX_DIRECT_CALLER_EVIDENCE);
715        if !callers.is_empty() {
716            callers_by_target.insert((*target_path).clone(), callers);
717        }
718    }
719    callers_by_target
720}
721
722/// Canonical CRAP formula: `CC^2 * (1 - cov/100)^3 + CC`.
723/// At 100% coverage: CRAP = CC. At 0% coverage: CRAP = CC^2 + CC.
724#[expect(
725    clippy::suboptimal_flops,
726    reason = "explicit multiplication matches the CRAP formula specification"
727)]
728fn crap_formula(cc: f64, coverage_pct: f64) -> f64 {
729    let uncovered = 1.0 - coverage_pct / 100.0;
730    cc * cc * uncovered * uncovered * uncovered + cc
731}
732
733/// Maximum column drift tolerated when the anonymous-by-position fallback
734/// matches a candidate on a nearby line. Wide enough to accept curried arrows
735/// and chained callbacks that share a leading indent, tight enough to reject
736/// `function foo()` at column 0 when the only candidate is a multiline-arrow
737/// declaration alias at the typical `const x = async (` column.
738const ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT: u32 = 16;
739
740/// Pre-processed per-function coverage data for a single file,
741/// derived from Istanbul `coverage-final.json`.
742pub struct IstanbulFileCoverage {
743    /// Per-function coverage percentages, keyed by (name, line, col). Lines
744    /// are 1-based and columns are 0-based, matching both fallow's
745    /// `FunctionComplexity` positions and Istanbul `Position`s.
746    ///
747    /// Istanbul producers are not consistent about `FnEntry.line`: some use
748    /// the declaration line, while others use the body start. The loader
749    /// therefore indexes both the producer's effective line and
750    /// `decl.start`, so multiline TypeScript signatures still match the
751    /// function start that fallow extracts.
752    functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
753}
754
755impl IstanbulFileCoverage {
756    /// Look up coverage for a function by name, start line, and start column.
757    ///
758    /// Resolution order:
759    /// 1. Exact `(name, line, col)` match.
760    /// 2. Name-only fuzzy match within ±2 lines (tolerates formatter drift),
761    ///    tie-broken by smallest `(line, col)` distance from the target.
762    /// 3. Anonymous fallback: among Istanbul `(anonymous_N)` entries within
763    ///    ±2 lines, pick the one closest in `(line, col)` to the target.
764    ///    Bail only if two candidates tie on distance, which would be
765    ///    genuinely ambiguous.
766    ///
767    /// Step 3 covers arrow-function exports where fallow extracts the binding
768    /// identifier (`const myHandler = () => {...}` yields `myHandler`) while
769    /// Istanbul records the function as anonymous. `load_istanbul_coverage`
770    /// indexes declaration aliases so standard Istanbul producers still
771    /// participate in this fallback. See issues #155, #166, #181, and #370.
772    pub fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
773        if let Some(&pct) = self.functions.get(&(name.to_string(), line, col)) {
774            return Some(pct);
775        }
776        if let Some(pct) = self
777            .functions
778            .iter()
779            .filter(|((n, l, _), _)| n == name && l.abs_diff(line) <= 2)
780            .min_by_key(|((_, l, c), _)| (l.abs_diff(line), c.abs_diff(col)))
781            .map(|(_, &pct)| pct)
782        {
783            return Some(pct);
784        }
785        let mut nearest_distance: Option<(u32, u32)> = None;
786        let mut nearest_pct: Option<f64> = None;
787        let mut tied = false;
788        for ((n, l, c), &pct) in &self.functions {
789            if !n.starts_with("(anonymous_") {
790                continue;
791            }
792            if l.abs_diff(line) > 2 {
793                continue;
794            }
795            let dist = (l.abs_diff(line), c.abs_diff(col));
796            if dist.0 > 0 && dist.1 > ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT {
797                continue;
798            }
799            match nearest_distance {
800                None => {
801                    nearest_distance = Some(dist);
802                    nearest_pct = Some(pct);
803                    tied = false;
804                }
805                Some(prev) if dist < prev => {
806                    nearest_distance = Some(dist);
807                    nearest_pct = Some(pct);
808                    tied = false;
809                }
810                Some(prev) if dist == prev => {
811                    tied = true;
812                }
813                Some(_) => {}
814            }
815        }
816        if tied { None } else { nearest_pct }
817    }
818}
819
820/// Loaded Istanbul coverage data, keyed by canonical file path.
821pub struct IstanbulCoverage {
822    files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
823}
824
825impl IstanbulCoverage {
826    /// Get coverage data for a file path.
827    pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
828        self.files.get(path)
829    }
830}
831
832/// Precedence decision for per-function CRAP coverage inputs.
833///
834/// Template inheritance wins first so Angular `.html` template findings can
835/// use the owning `.component.ts` reachability context. Istanbul wins next,
836/// even when the current file is missing from the coverage map, because that
837/// path still records unmatched functions in the run-level match counters.
838/// Plain graph-estimated coverage is the final fallback.
839enum CrapCoverageResolution<'a> {
840    TemplateInherited(&'a TemplateInheritContext),
841    Istanbul {
842        file_coverage: Option<&'a IstanbulFileCoverage>,
843    },
844    StaticEstimated,
845}
846
847fn resolve_crap_coverage<'a>(
848    template_inherit: Option<&'a TemplateInheritContext>,
849    istanbul_coverage: Option<&'a IstanbulCoverage>,
850    path: &std::path::Path,
851) -> CrapCoverageResolution<'a> {
852    if let Some(inherit_ctx) = template_inherit {
853        CrapCoverageResolution::TemplateInherited(inherit_ctx)
854    } else if let Some(istanbul) = istanbul_coverage {
855        let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
856        CrapCoverageResolution::Istanbul {
857            file_coverage: istanbul.get(&canonical),
858        }
859    } else {
860        CrapCoverageResolution::StaticEstimated
861    }
862}
863
864/// Load Istanbul coverage data from a `coverage-final.json` file or directory.
865///
866/// Auto-detect a `coverage-final.json` file in common locations relative to the project root.
867///
868/// Checks (in order): `coverage/coverage-final.json`, `.nyc_output/coverage-final.json`.
869/// Returns the first path found, or `None` if no coverage file exists.
870pub(super) fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
871    let candidates = [
872        root.join("coverage/coverage-final.json"),
873        root.join(".nyc_output/coverage-final.json"),
874    ];
875    candidates.into_iter().find(|p| p.is_file())
876}
877
878/// Resolve a relative path against the fallow project root. Returns `path`
879/// unchanged when it is absolute or `project_root` is `None`. Matches the
880/// convention every other path-shaped CLI input uses, so a monorepo CI run
881/// invoked from the workspace root with `--root sub-project` finds
882/// `sub-project/relative/path.json` instead of `cwd/relative/path.json`.
883pub fn resolve_relative_to_root(
884    path: &std::path::Path,
885    project_root: Option<&std::path::Path>,
886) -> std::path::PathBuf {
887    if fallow_types::path_util::is_absolute_path_any_platform(path) {
888        return path.to_path_buf();
889    }
890    match project_root {
891        Some(root) => root.join(path),
892        None => path.to_path_buf(),
893    }
894}
895
896/// If `path` is a directory, looks for `coverage-final.json` inside it.
897/// Parses the Istanbul JSON format and pre-computes per-function statement
898/// coverage percentages for efficient lookup during CRAP scoring.
899///
900/// When `coverage_root` is provided, file paths in the Istanbul data are rebased:
901/// the `coverage_root` prefix is stripped and `project_root` is prepended, enabling
902/// cross-environment matching (e.g., coverage from CI used on a local checkout).
903///
904/// `path` itself is resolved against `project_root` when relative, so callers
905/// can pass `--coverage coverage/foo.json` from a parent directory and have it
906/// land under the `--root` they configured.
907pub(super) fn load_istanbul_coverage(
908    path: &std::path::Path,
909    coverage_root: Option<&std::path::Path>,
910    project_root: Option<&std::path::Path>,
911) -> Result<IstanbulCoverage, String> {
912    super::validate_coverage_root_absolute(coverage_root)?;
913    let resolved = resolve_relative_to_root(path, project_root);
914    let file_path = if resolved.is_dir() {
915        let candidate = resolved.join("coverage-final.json");
916        if candidate.is_file() {
917            candidate
918        } else {
919            return Err(format!(
920                "no coverage-final.json found in {}",
921                resolved.display()
922            ));
923        }
924    } else {
925        resolved
926    };
927
928    let json = std::fs::read_to_string(&file_path)
929        .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
930
931    let raw: std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage> =
932        oxc_coverage_instrument::parse_coverage_map(&json).map_err(|e| {
933            format!(
934                "failed to parse coverage data from {}: {e}",
935                file_path.display()
936            )
937        })?;
938
939    let mut files = rustc_hash::FxHashMap::default();
940    for file_cov in raw.values() {
941        let raw_path = std::path::PathBuf::from(&file_cov.path);
942        let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
943            raw_path
944                .strip_prefix(cov_root)
945                .map(|rel| proj_root.join(rel))
946                .unwrap_or(raw_path)
947        } else {
948            raw_path
949        };
950        let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
951
952        let mut functions = rustc_hash::FxHashMap::default();
953        for (fn_id, fn_entry) in &file_cov.fn_map {
954            let coverage_pct = compute_function_statement_coverage(file_cov, fn_id, fn_entry);
955            insert_istanbul_function_coverage(&mut functions, fn_entry, coverage_pct);
956        }
957
958        files.insert(canonical, IstanbulFileCoverage { functions });
959    }
960
961    Ok(IstanbulCoverage { files })
962}
963
964fn insert_istanbul_function_coverage(
965    functions: &mut rustc_hash::FxHashMap<(String, u32, u32), f64>,
966    fn_entry: &oxc_coverage_instrument::FnEntry,
967    coverage_pct: f64,
968) {
969    let name = fn_entry.name.clone();
970    let primary = (
971        name.clone(),
972        effective_istanbul_fn_line(fn_entry),
973        effective_istanbul_fn_col(fn_entry),
974    );
975    functions.insert(primary.clone(), coverage_pct);
976
977    let declaration = (name, fn_entry.decl.start.line, fn_entry.decl.start.column);
978    if declaration != primary {
979        functions.entry(declaration).or_insert(coverage_pct);
980    }
981}
982
983fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
984    if fn_entry.line > 0 {
985        fn_entry.line
986    } else {
987        fn_entry.decl.start.line
988    }
989}
990
991/// Effective 0-based start column for an Istanbul function entry. `FnEntry`
992/// has no top-level `column` field, so we always read it off
993/// `decl.start.column`. Both fallow's `FunctionComplexity.col` and Istanbul's
994/// `Position::column` are 0-based, so they match directly.
995fn effective_istanbul_fn_col(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
996    fn_entry.decl.start.column
997}
998
999/// Compute statement-level coverage percentage for a single function.
1000///
1001/// Maps statements from `statementMap` to the function's body range (`loc`)
1002/// and computes the fraction with non-zero hit counts. When no statements
1003/// fall within the function body (e.g., one-liner arrow functions, getters),
1004/// falls back to the function hit count as a binary signal.
1005fn compute_function_statement_coverage(
1006    file_cov: &oxc_coverage_instrument::FileCoverage,
1007    fn_id: &str,
1008    fn_entry: &oxc_coverage_instrument::FnEntry,
1009) -> f64 {
1010    let fn_start_line = fn_entry.loc.start.line;
1011    let fn_start_col = fn_entry.loc.start.column;
1012    let fn_end_line = fn_entry.loc.end.line;
1013    let fn_end_col = fn_entry.loc.end.column;
1014
1015    let mut total = 0u32;
1016    let mut covered = 0u32;
1017
1018    for (stmt_id, stmt_loc) in &file_cov.statement_map {
1019        let after_start = stmt_loc.start.line > fn_start_line
1020            || (stmt_loc.start.line == fn_start_line && stmt_loc.start.column >= fn_start_col);
1021        let before_end = stmt_loc.end.line < fn_end_line
1022            || (stmt_loc.end.line == fn_end_line && stmt_loc.end.column <= fn_end_col);
1023
1024        if after_start && before_end {
1025            total += 1;
1026            if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
1027                covered += 1;
1028            }
1029        }
1030    }
1031
1032    if total == 0 {
1033        let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
1034        if hit > 0 { 100.0 } else { 0.0 }
1035    } else {
1036        f64::from(covered) / f64::from(total) * 100.0
1037    }
1038}
1039
1040/// Count unused VALUE exports per file path for O(1) lookup.
1041///
1042/// Type-only exports (interfaces, type aliases) are intentionally excluded ---
1043/// they are a different concern than unused functions/components.
1044fn count_unused_exports_by_path(
1045    unused_exports: &[crate::results::UnusedExportFinding],
1046) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
1047    let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
1048    for exp in unused_exports {
1049        *map.entry(exp.export.path.as_path()).or_default() += 1;
1050    }
1051    map
1052}
1053
1054/// Compute the maintainability index for a single file.
1055///
1056/// Formula:
1057/// ```text
1058/// dampening = min(lines / 50, 1.0)
1059/// fan_out_penalty = min(ln(fan_out + 1) * 4, 15)
1060/// MI = 100 - (complexity_density * 30 * dampening) - (dead_code_ratio * 20) - fan_out_penalty
1061/// ```
1062///
1063/// The dampening factor prevents complexity density from dominating the score
1064/// on small files. A 5-line utility with CC=2 has density 0.40, but is trivially
1065/// readable; without dampening it scores worse than a 192-line function with CC=57
1066/// (density 0.30). Files under 50 lines get proportionally reduced density weight.
1067///
1068/// Fan-out uses logarithmic scaling capped at 15 points to reflect diminishing
1069/// marginal risk (the 30th import is less concerning than the 5th) and prevent
1070/// composition-root files from being unfairly penalized.
1071///
1072/// Clamped to \[0, 100\]. Higher is better.
1073fn compute_maintainability_index(
1074    complexity_density: f64,
1075    dead_code_ratio: f64,
1076    fan_out: usize,
1077    lines: u32,
1078) -> f64 {
1079    let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
1080    let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
1081    #[expect(
1082        clippy::suboptimal_flops,
1083        reason = "formula matches documented specification"
1084    )]
1085    let score = 100.0
1086        - (complexity_density * 30.0 * dampening)
1087        - (dead_code_ratio * 20.0)
1088        - fan_out_penalty;
1089    score.clamp(0.0, 100.0)
1090}
1091
1092fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
1093    (100.0 - score.maintainability_index).clamp(0.0, 100.0)
1094}
1095
1096fn file_score_crap_concern(crap_max: f64) -> f64 {
1097    if crap_max <= 0.0 {
1098        0.0
1099    } else if crap_max < 15.0 {
1100        (crap_max / 15.0) * 45.0
1101    } else if crap_max < CRAP_THRESHOLD {
1102        ((crap_max - 15.0) / 15.0).mul_add(30.0, 45.0)
1103    } else if crap_max < 100.0 {
1104        ((crap_max - CRAP_THRESHOLD) / (100.0 - CRAP_THRESHOLD)).mul_add(25.0, 75.0)
1105    } else {
1106        100.0
1107    }
1108}
1109
1110fn file_score_triage_concern(score: &FileHealthScore) -> f64 {
1111    file_score_structural_concern(score).max(file_score_crap_concern(score.crap_max))
1112}
1113
1114/// Which signal places a file at its triage rank: its structural quality (low
1115/// maintainability index) or its untested complexity (CRAP risk). Surfaced per
1116/// row so the human file-scores table can label why a file sits where it does
1117/// when the two axes disagree (e.g. a low-CRAP file outranking a higher-CRAP
1118/// one because its MI is the worse signal).
1119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1120pub enum FileScoreConcern {
1121    Structural,
1122    Risk,
1123}
1124
1125impl FileScoreConcern {
1126    /// Short lowercase label for the human file-scores table.
1127    pub const fn label(self) -> &'static str {
1128        match self {
1129            Self::Structural => "structure",
1130            Self::Risk => "risk",
1131        }
1132    }
1133}
1134
1135/// Classify which concern drove `score` to its rank. A file with no CRAP risk
1136/// is always `Structural`; otherwise the larger concern wins, with ties (and
1137/// the boundary where the two are equal) resolving to `Risk` because untested
1138/// complexity is the more urgent signal to act on.
1139pub fn file_score_concern_axis(score: &FileHealthScore) -> FileScoreConcern {
1140    if score.crap_max <= 0.0 {
1141        FileScoreConcern::Structural
1142    } else if file_score_crap_concern(score.crap_max) >= file_score_structural_concern(score) {
1143        FileScoreConcern::Risk
1144    } else {
1145        FileScoreConcern::Structural
1146    }
1147}
1148
1149fn compare_file_score_triage(a: &FileHealthScore, b: &FileHealthScore) -> std::cmp::Ordering {
1150    file_score_triage_concern(b)
1151        .total_cmp(&file_score_triage_concern(a))
1152        .then_with(|| b.crap_max.total_cmp(&a.crap_max))
1153        .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
1154        .then_with(|| a.path.cmp(&b.path))
1155}
1156
1157/// Compute per-file health scores using a pre-computed analysis output.
1158///
1159/// The caller provides an `AnalysisOutput` (with graph and dead code results)
1160/// so this function does not need to re-run the analysis pipeline. Complexity
1161/// density is derived from the already-parsed modules.
1162pub(super) fn compute_file_scores(
1163    modules: &[crate::source::ModuleInfo],
1164    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1165    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1166    analysis_output: crate::results::DeadCodeAnalysisArtifacts,
1167    istanbul_coverage: Option<&IstanbulCoverage>,
1168    root: &std::path::Path,
1169) -> Result<FileScoreOutput, String> {
1170    let retained_graph = analysis_output.graph.ok_or("graph not available")?;
1171    let test_coverage = retained_graph.static_test_coverage();
1172    let graph = retained_graph.as_graph();
1173    let results = &analysis_output.results;
1174
1175    let circular_files = collect_circular_files(results);
1176    let top_complex_fns = collect_top_complex_fns(modules, file_paths);
1177    let cycle_members = collect_cycle_members(results);
1178    let direct_callers = collect_direct_callers(graph, file_paths);
1179    let unused_export_names = collect_unused_export_names(results);
1180
1181    let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
1182        .unused_files
1183        .iter()
1184        .map(|f| f.file.path.as_path())
1185        .collect();
1186
1187    let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
1188
1189    let FileScoreCoverageSetup {
1190        module_by_id,
1191        coverage,
1192    } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
1193
1194    let template_inherit =
1195        build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
1196
1197    let mut acc = accumulate_file_scores(
1198        unused_export_names,
1199        &FileScoreLoopCtx {
1200            graph,
1201            test_coverage,
1202            file_paths,
1203            module_by_id: &module_by_id,
1204            unused_files: &unused_files,
1205            unused_exports_by_path: &unused_exports_by_path,
1206            template_inherit: &template_inherit,
1207            istanbul_coverage,
1208        },
1209    );
1210    acc.scores = finalize_file_score_list(acc.scores, changed_files);
1211
1212    Ok(build_file_score_output(FileScoreOutputParts {
1213        graph,
1214        file_paths,
1215        results,
1216        scores: acc.scores,
1217        coverage,
1218        circular_files,
1219        top_complex_fns,
1220        entry_points: acc.entry_points,
1221        value_export_counts: acc.value_export_counts,
1222        unused_export_names: acc.unused_export_names,
1223        cycle_members,
1224        direct_callers,
1225        istanbul_matched: acc.istanbul_matched,
1226        istanbul_total: acc.istanbul_total,
1227        per_function_crap: acc.per_function_crap,
1228        template_inherit,
1229    }))
1230}
1231
1232/// Read-only inputs threaded into the per-node file-score loop.
1233struct FileScoreLoopCtx<'a> {
1234    graph: &'a fallow_graph::graph::ModuleGraph,
1235    test_coverage: StaticTestCoverage<'a>,
1236    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1237    module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1238    unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
1239    unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
1240    template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1241    istanbul_coverage: Option<&'a IstanbulCoverage>,
1242}
1243
1244/// Mutable accumulators populated by the per-node file-score loop.
1245struct FileScoreAccumulator {
1246    scores: Vec<FileHealthScore>,
1247    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
1248    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
1249    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1250    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1251    istanbul_matched: usize,
1252    istanbul_total: usize,
1253}
1254
1255impl FileScoreAccumulator {
1256    /// Empty accumulator with the score vector pre-sized to the module count.
1257    fn with_capacity(modules: usize) -> Self {
1258        FileScoreAccumulator {
1259            scores: Vec::with_capacity(modules),
1260            entry_points: rustc_hash::FxHashSet::default(),
1261            value_export_counts: rustc_hash::FxHashMap::default(),
1262            unused_export_names: rustc_hash::FxHashMap::default(),
1263            per_function_crap: rustc_hash::FxHashMap::default(),
1264            istanbul_matched: 0,
1265            istanbul_total: 0,
1266        }
1267    }
1268}
1269
1270/// Drive the per-node loop, returning an accumulator with one score per
1271/// analyzable file. `unused_export_names` seeds the accumulator's same field.
1272fn accumulate_file_scores(
1273    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1274    ctx: &FileScoreLoopCtx<'_>,
1275) -> FileScoreAccumulator {
1276    let mut acc = FileScoreAccumulator {
1277        unused_export_names,
1278        ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
1279    };
1280    for node in &ctx.graph.modules {
1281        let Some(path) = ctx.file_paths.get(&node.file_id) else {
1282            continue;
1283        };
1284        record_entry_point(&mut acc.entry_points, node, path);
1285        let score = compute_one_file_score(&mut acc, ctx, node, path);
1286        acc.scores.push(score);
1287    }
1288    acc
1289}
1290
1291/// Apply the changed-file scope filter, drop zero-function barrels, and sort by
1292/// risk-aware triage concern.
1293fn finalize_file_score_list(
1294    mut scores: Vec<FileHealthScore>,
1295    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1296) -> Vec<FileHealthScore> {
1297    if let Some(changed) = changed_files {
1298        scores.retain(|s| changed.contains(&s.path));
1299    }
1300    scores.retain(|s| s.function_count > 0);
1301    scores.sort_by(compare_file_score_triage);
1302    scores
1303}
1304
1305/// Compute the `FileHealthScore` for one node and fold its side data into `acc`.
1306fn compute_one_file_score(
1307    acc: &mut FileScoreAccumulator,
1308    ctx: &FileScoreLoopCtx<'_>,
1309    node: &fallow_graph::graph::ModuleNode,
1310    path: &std::path::Path,
1311) -> FileHealthScore {
1312    let fan_in = ctx
1313        .graph
1314        .reverse_deps
1315        .get(node.file_id.0 as usize)
1316        .map_or(0, Vec::len);
1317    let fan_out = node.edge_range.len();
1318
1319    let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
1320        .module_by_id
1321        .get(&node.file_id)
1322        .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
1323
1324    let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
1325    let path_owned = path.to_path_buf();
1326    acc.value_export_counts
1327        .insert(path_owned.clone(), value_exports);
1328    record_unused_file_export_names(
1329        path_owned.as_path(),
1330        &node.exports,
1331        ctx.unused_files,
1332        &mut acc.unused_export_names,
1333    );
1334
1335    let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
1336        compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
1337
1338    let crap = compute_file_score_crap(
1339        node,
1340        ctx.module_by_id.get(&node.file_id).copied(),
1341        ctx.test_coverage,
1342        ctx.template_inherit.get(&node.file_id),
1343        ctx.istanbul_coverage,
1344        &path_owned,
1345    );
1346    acc.istanbul_matched += crap.istanbul_matched;
1347    acc.istanbul_total += crap.istanbul_total;
1348    record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
1349
1350    FileHealthScore {
1351        path: path_owned,
1352        fan_in,
1353        fan_out,
1354        dead_code_ratio: dead_code_ratio_rounded,
1355        complexity_density: complexity_density_rounded,
1356        maintainability_index: maintainability_index_rounded,
1357        total_cyclomatic,
1358        total_cognitive,
1359        function_count,
1360        lines,
1361        crap_max: crap.max,
1362        crap_above_threshold: crap.above_threshold,
1363    }
1364}
1365
1366/// Compute the rounded dead-code-ratio, complexity-density, and
1367/// maintainability-index metrics for one file.
1368fn compute_file_score_metrics(
1369    node: &fallow_graph::graph::ModuleNode,
1370    path: &std::path::Path,
1371    ctx: &FileScoreLoopCtx<'_>,
1372    total_cyclomatic: u32,
1373    lines: u32,
1374    fan_out: usize,
1375) -> (f64, f64, f64) {
1376    let dead_code_ratio = compute_dead_code_ratio(
1377        path,
1378        &node.exports,
1379        ctx.unused_files,
1380        ctx.unused_exports_by_path,
1381    );
1382    let complexity_density = compute_complexity_density(total_cyclomatic, lines);
1383
1384    let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
1385    let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
1386
1387    let maintainability_index = compute_maintainability_index(
1388        complexity_density_rounded,
1389        dead_code_ratio_rounded,
1390        fan_out,
1391        lines,
1392    );
1393    (
1394        dead_code_ratio_rounded,
1395        complexity_density_rounded,
1396        (maintainability_index * 10.0).round() / 10.0,
1397    )
1398}
1399
1400fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
1401    let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
1402    let unused_deps = parts.results.unused_dependencies.len()
1403        + parts.results.unused_dev_dependencies.len()
1404        + parts.results.unused_optional_dependencies.len();
1405    let analysis_snapshot =
1406        build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
1407    let analysis_counts =
1408        build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
1409    let template_inherit_provenance =
1410        build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
1411
1412    FileScoreOutput {
1413        scores: parts.scores,
1414        coverage: parts.coverage,
1415        circular_files: parts.circular_files,
1416        top_complex_fns: parts.top_complex_fns,
1417        entry_points: parts.entry_points,
1418        value_export_counts: parts.value_export_counts,
1419        unused_export_names: parts.unused_export_names,
1420        cycle_members: parts.cycle_members,
1421        direct_callers: parts.direct_callers,
1422        analysis_counts,
1423        prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
1424        render_fan_in: parts.results.render_fan_in.clone(),
1425        analysis_snapshot,
1426        istanbul_matched: parts.istanbul_matched,
1427        istanbul_total: parts.istanbul_total,
1428        per_function_crap: parts.per_function_crap,
1429        template_inherit_provenance,
1430    }
1431}
1432
1433fn build_file_score_analysis_counts(
1434    results: &crate::results::AnalysisResults,
1435    total_exports: usize,
1436    unused_deps: usize,
1437) -> crate::vital_signs::AnalysisCounts {
1438    crate::vital_signs::AnalysisCounts {
1439        total_exports,
1440        dead_files: results.unused_files.len(),
1441        dead_exports: results.unused_exports.len() + results.unused_types.len(),
1442        unused_deps,
1443        circular_deps: results.circular_dependencies.len(),
1444        total_deps: 0usize,
1445    }
1446}
1447
1448fn build_template_inherit_provenance(
1449    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1450    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1451) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
1452    template_inherit
1453        .into_iter()
1454        .filter_map(|(file_id, ctx)| {
1455            file_paths
1456                .get(&file_id)
1457                .map(|path| ((**path).clone(), ctx.provenance_owner))
1458        })
1459        .collect()
1460}
1461
1462fn record_entry_point(
1463    entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
1464    node: &fallow_graph::graph::ModuleNode,
1465    path: &std::path::Path,
1466) {
1467    if node.is_entry_point() {
1468        entry_points.insert(path.to_path_buf());
1469    }
1470}
1471
1472fn record_unused_file_export_names(
1473    path: &std::path::Path,
1474    exports: &[fallow_graph::graph::ExportSymbol],
1475    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
1476    unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1477) {
1478    if !unused_files.contains(path) || unused_export_names.contains_key(path) {
1479        return;
1480    }
1481
1482    let names: Vec<String> = exports
1483        .iter()
1484        .filter(|export| !export.is_type_only)
1485        .map(|export| export.name.to_string())
1486        .collect();
1487    if !names.is_empty() {
1488        unused_export_names.insert(path.to_path_buf(), names);
1489    }
1490}
1491
1492struct FileScoreCrap {
1493    max: f64,
1494    above_threshold: usize,
1495    per_function: Vec<PerFunctionCrap>,
1496    istanbul_matched: usize,
1497    istanbul_total: usize,
1498}
1499
1500impl FileScoreCrap {
1501    fn empty() -> Self {
1502        Self {
1503            max: 0.0,
1504            above_threshold: 0,
1505            per_function: Vec::new(),
1506            istanbul_matched: 0,
1507            istanbul_total: 0,
1508        }
1509    }
1510
1511    fn estimated(result: EstimatedCrapResult) -> Self {
1512        Self {
1513            max: result.max_crap,
1514            above_threshold: result.above_threshold,
1515            per_function: result.per_function,
1516            istanbul_matched: 0,
1517            istanbul_total: 0,
1518        }
1519    }
1520
1521    fn istanbul(result: IstanbulCrapResult) -> Self {
1522        Self {
1523            max: result.max_crap,
1524            above_threshold: result.above_threshold,
1525            per_function: result.per_function,
1526            istanbul_matched: result.matched,
1527            istanbul_total: result.total,
1528        }
1529    }
1530}
1531
1532fn compute_file_score_crap(
1533    node: &fallow_graph::graph::ModuleNode,
1534    module: Option<&crate::source::ModuleInfo>,
1535    test_coverage: StaticTestCoverage<'_>,
1536    template_inherit: Option<&TemplateInheritContext>,
1537    istanbul_coverage: Option<&IstanbulCoverage>,
1538    path: &std::path::Path,
1539) -> FileScoreCrap {
1540    let Some(module) = module else {
1541        return FileScoreCrap::empty();
1542    };
1543
1544    let is_coverage_suppressed = crate::suppress::is_file_suppressed(
1545        &module.suppressions,
1546        fallow_types::suppress::IssueKind::CoverageGaps,
1547    );
1548    let is_test_reachable = test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
1549    let resolution = resolve_crap_coverage(template_inherit, istanbul_coverage, path);
1550    match resolution {
1551        CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
1552            compute_template_inherited_crap(module, inherit_ctx)
1553        }
1554        CrapCoverageResolution::Istanbul { file_coverage } => {
1555            compute_istanbul_file_crap(module, file_coverage, is_test_reachable)
1556        }
1557        CrapCoverageResolution::StaticEstimated => {
1558            compute_static_file_crap(module, &node.exports, test_coverage, is_test_reachable)
1559        }
1560    }
1561}
1562
1563fn compute_template_inherited_crap(
1564    module: &crate::source::ModuleInfo,
1565    inherit_ctx: &TemplateInheritContext,
1566) -> FileScoreCrap {
1567    FileScoreCrap::estimated(compute_crap_scores_estimated(
1568        &module.complexity,
1569        &inherit_ctx.test_referenced_exports,
1570        inherit_ctx.is_test_reachable,
1571        fallow_output::CoverageSource::EstimatedComponentInherited,
1572    ))
1573}
1574
1575fn compute_istanbul_file_crap(
1576    module: &crate::source::ModuleInfo,
1577    file_coverage: Option<&IstanbulFileCoverage>,
1578    is_test_reachable: bool,
1579) -> FileScoreCrap {
1580    FileScoreCrap::istanbul(compute_crap_scores_istanbul(
1581        &module.complexity,
1582        file_coverage,
1583        is_test_reachable,
1584    ))
1585}
1586
1587fn compute_static_file_crap(
1588    module: &crate::source::ModuleInfo,
1589    exports: &[fallow_graph::graph::ExportSymbol],
1590    test_coverage: StaticTestCoverage<'_>,
1591    is_test_reachable: bool,
1592) -> FileScoreCrap {
1593    let test_refs = build_test_referenced_exports(exports, test_coverage);
1594    FileScoreCrap::estimated(compute_crap_scores_estimated(
1595        &module.complexity,
1596        &test_refs,
1597        is_test_reachable,
1598        fallow_output::CoverageSource::Estimated,
1599    ))
1600}
1601
1602fn record_per_function_crap(
1603    per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1604    path: &std::path::Path,
1605    per_function: Vec<PerFunctionCrap>,
1606) {
1607    if !per_function.is_empty() {
1608        per_function_crap.insert(path.to_path_buf(), per_function);
1609    }
1610}
1611
1612struct FileScoreCoverageSetup<'a> {
1613    module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1614    coverage: CoverageGapData,
1615}
1616
1617fn prepare_file_score_coverage_setup<'a>(
1618    modules: &'a [crate::source::ModuleInfo],
1619    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1620    results: &crate::results::AnalysisResults,
1621    graph: &fallow_graph::graph::ModuleGraph,
1622    test_coverage: StaticTestCoverage<'_>,
1623    root: &std::path::Path,
1624) -> FileScoreCoverageSetup<'a> {
1625    let module_by_id: rustc_hash::FxHashMap<_, _> =
1626        modules.iter().map(|m| (m.file_id, m)).collect();
1627    let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
1628        .unused_exports
1629        .iter()
1630        .map(|export| {
1631            (
1632                export.export.path.as_path(),
1633                export.export.export_name.clone(),
1634            )
1635        })
1636        .collect();
1637    let coverage = compute_coverage_gaps(
1638        graph,
1639        test_coverage,
1640        file_paths,
1641        &module_by_id,
1642        &unused_exports,
1643        root,
1644    );
1645    FileScoreCoverageSetup {
1646        module_by_id,
1647        coverage,
1648    }
1649}
1650
1651fn collect_circular_files(
1652    results: &crate::results::AnalysisResults,
1653) -> rustc_hash::FxHashSet<std::path::PathBuf> {
1654    results
1655        .circular_dependencies
1656        .iter()
1657        .flat_map(|c| c.cycle.files.iter().cloned())
1658        .collect()
1659}
1660
1661fn collect_top_complex_fns(
1662    modules: &[crate::source::ModuleInfo],
1663    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1664) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
1665    let mut top_complex_fns = rustc_hash::FxHashMap::default();
1666    for module in modules {
1667        if module.complexity.is_empty() {
1668            continue;
1669        }
1670        let Some(path) = file_paths.get(&module.file_id) else {
1671            continue;
1672        };
1673        let mut funcs: Vec<(String, u32, u16)> = module
1674            .complexity
1675            .iter()
1676            .map(|f| (f.name.clone(), f.line, f.cognitive))
1677            .collect();
1678        funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
1679        funcs.truncate(3);
1680        if funcs[0].2 > 0 {
1681            top_complex_fns.insert((*path).clone(), funcs);
1682        }
1683    }
1684    top_complex_fns
1685}
1686
1687fn collect_cycle_members(
1688    results: &crate::results::AnalysisResults,
1689) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
1690    let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
1691        rustc_hash::FxHashMap::default();
1692    for cycle in &results.circular_dependencies {
1693        for file in &cycle.cycle.files {
1694            let others: Vec<std::path::PathBuf> = cycle
1695                .cycle
1696                .files
1697                .iter()
1698                .filter(|f| *f != file)
1699                .cloned()
1700                .collect();
1701            cycle_members
1702                .entry(file.clone())
1703                .or_default()
1704                .extend(others);
1705        }
1706    }
1707    for members in cycle_members.values_mut() {
1708        members.sort();
1709        members.dedup();
1710    }
1711    cycle_members
1712}
1713
1714fn collect_unused_export_names(
1715    results: &crate::results::AnalysisResults,
1716) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
1717    let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
1718        rustc_hash::FxHashMap::default();
1719    for exp in &results.unused_exports {
1720        unused_export_names
1721            .entry(exp.export.path.clone())
1722            .or_default()
1723            .push(exp.export.export_name.clone());
1724    }
1725    unused_export_names
1726}
1727
1728fn build_analysis_counts_snapshot(
1729    graph: &fallow_graph::graph::ModuleGraph,
1730    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1731    results: &crate::results::AnalysisResults,
1732    unused_deps: usize,
1733) -> AnalysisCountsSnapshot {
1734    let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
1735        graph.modules.len(),
1736        rustc_hash::FxBuildHasher,
1737    );
1738    for module in &graph.modules {
1739        if let Some(path) = file_paths.get(&module.file_id) {
1740            module_export_counts.insert((*path).clone(), module.exports.len());
1741        }
1742    }
1743
1744    let mut unused_export_paths =
1745        Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
1746    unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
1747    unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
1748
1749    let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
1750    unused_dep_package_paths.extend(
1751        results
1752            .unused_dependencies
1753            .iter()
1754            .map(|d| d.dep.path.clone()),
1755    );
1756    unused_dep_package_paths.extend(
1757        results
1758            .unused_dev_dependencies
1759            .iter()
1760            .map(|d| d.dep.path.clone()),
1761    );
1762    unused_dep_package_paths.extend(
1763        results
1764            .unused_optional_dependencies
1765            .iter()
1766            .map(|d| d.dep.path.clone()),
1767    );
1768
1769    AnalysisCountsSnapshot {
1770        unused_file_paths: results
1771            .unused_files
1772            .iter()
1773            .map(|f| f.file.path.clone())
1774            .collect(),
1775        unused_export_paths,
1776        unused_dep_package_paths,
1777        circular_dep_groups: results
1778            .circular_dependencies
1779            .iter()
1780            .map(|c| c.cycle.files.clone())
1781            .collect(),
1782        module_export_counts,
1783    }
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789
1790    #[test]
1791    fn maintainability_perfect_score() {
1792        assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
1793    }
1794
1795    #[test]
1796    fn crap_resolution_prefers_template_inheritance_over_istanbul() {
1797        let inherit_ctx = TemplateInheritContext {
1798            is_test_reachable: true,
1799            test_referenced_exports: rustc_hash::FxHashSet::default(),
1800            provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
1801        };
1802        let istanbul = IstanbulCoverage {
1803            files: rustc_hash::FxHashMap::default(),
1804        };
1805
1806        let resolution = resolve_crap_coverage(
1807            Some(&inherit_ctx),
1808            Some(&istanbul),
1809            std::path::Path::new("/project/src/app.component.html"),
1810        );
1811
1812        assert!(matches!(
1813            resolution,
1814            CrapCoverageResolution::TemplateInherited(_)
1815        ));
1816    }
1817
1818    #[test]
1819    fn crap_resolution_keeps_istanbul_when_file_is_missing() {
1820        let istanbul = IstanbulCoverage {
1821            files: rustc_hash::FxHashMap::default(),
1822        };
1823
1824        let resolution = resolve_crap_coverage(
1825            None,
1826            Some(&istanbul),
1827            std::path::Path::new("/project/src/missing.ts"),
1828        );
1829
1830        assert!(matches!(
1831            resolution,
1832            CrapCoverageResolution::Istanbul {
1833                file_coverage: None
1834            }
1835        ));
1836    }
1837
1838    #[test]
1839    fn maintainability_clamped_at_zero() {
1840        assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
1841    }
1842
1843    #[test]
1844    fn maintainability_formula_correct() {
1845        let result = compute_maintainability_index(0.5, 0.3, 10, 100);
1846        let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
1847        assert!((result - expected).abs() < 0.01);
1848    }
1849
1850    #[test]
1851    fn maintainability_dead_file_penalty() {
1852        let result = compute_maintainability_index(0.0, 1.0, 0, 100);
1853        assert!((result - 80.0).abs() < f64::EPSILON);
1854    }
1855
1856    #[test]
1857    fn maintainability_fan_out_is_logarithmic() {
1858        let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
1859        let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
1860        let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
1861
1862        assert!(result_10 > 90.0); // ~90.4
1863        assert!(result_100 > 84.0); // 85.0 (capped)
1864        assert!((result_100 - result_200).abs() < f64::EPSILON);
1865    }
1866
1867    #[test]
1868    fn maintainability_fan_out_capped_at_15() {
1869        let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
1870        assert!((result - 65.0).abs() < f64::EPSILON);
1871    }
1872
1873    #[test]
1874    fn maintainability_small_file_dampened() {
1875        let small = compute_maintainability_index(0.40, 0.0, 0, 5);
1876        assert!((small - 98.8).abs() < 0.01);
1877    }
1878
1879    #[test]
1880    fn maintainability_large_file_undampened() {
1881        let large = compute_maintainability_index(0.30, 0.0, 0, 192);
1882        assert!((large - 91.0).abs() < 0.01);
1883    }
1884
1885    #[test]
1886    fn maintainability_small_file_ranks_better_than_complex_large_file() {
1887        let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
1888        let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
1889        assert!(
1890            trivial > nightmare,
1891            "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
1892        );
1893    }
1894
1895    #[test]
1896    fn maintainability_at_dampening_boundary() {
1897        let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
1898        let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
1899        assert!((at_boundary - above_boundary).abs() < 0.01);
1900    }
1901
1902    #[test]
1903    fn maintainability_zero_lines_zero_density_penalty() {
1904        let result = compute_maintainability_index(5.0, 0.0, 0, 0);
1905        assert!((result - 100.0).abs() < f64::EPSILON);
1906    }
1907
1908    #[test]
1909    fn complexity_density_zero_lines() {
1910        assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
1911    }
1912
1913    #[test]
1914    fn complexity_density_normal() {
1915        let result = compute_complexity_density(10, 100);
1916        assert!((result - 0.1).abs() < f64::EPSILON);
1917    }
1918
1919    #[test]
1920    fn complexity_density_high() {
1921        let result = compute_complexity_density(50, 10);
1922        assert!((result - 5.0).abs() < f64::EPSILON);
1923    }
1924
1925    #[test]
1926    fn dead_code_ratio_no_exports() {
1927        let unused_files = rustc_hash::FxHashSet::default();
1928        let unused_map = rustc_hash::FxHashMap::default();
1929        let path = std::path::Path::new("/src/foo.ts");
1930        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
1931
1932        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
1933        assert!((ratio).abs() < f64::EPSILON);
1934    }
1935
1936    #[test]
1937    fn dead_code_ratio_all_unused_file() {
1938        let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
1939            rustc_hash::FxHashSet::default();
1940        let path = std::path::Path::new("/src/foo.ts");
1941        unused_files.insert(path);
1942        let unused_map = rustc_hash::FxHashMap::default();
1943        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
1944
1945        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
1946        assert!((ratio - 1.0).abs() < f64::EPSILON);
1947    }
1948
1949    #[test]
1950    fn dead_code_ratio_mix() {
1951        let unused_files = rustc_hash::FxHashSet::default();
1952        let path = std::path::Path::new("/src/foo.ts");
1953
1954        let exports = vec![
1955            fallow_graph::graph::ExportSymbol {
1956                name: crate::source::ExportName::Named("a".into()),
1957                is_type_only: false,
1958                is_side_effect_used: false,
1959                visibility: crate::source::VisibilityTag::None,
1960                expected_unused_reason: None,
1961                span: oxc_span::Span::empty(0),
1962                references: vec![],
1963                members: vec![],
1964            },
1965            fallow_graph::graph::ExportSymbol {
1966                name: crate::source::ExportName::Named("b".into()),
1967                is_type_only: false,
1968                is_side_effect_used: false,
1969                visibility: crate::source::VisibilityTag::None,
1970                expected_unused_reason: None,
1971                span: oxc_span::Span::empty(0),
1972                references: vec![],
1973                members: vec![],
1974            },
1975            fallow_graph::graph::ExportSymbol {
1976                name: crate::source::ExportName::Named("c".into()),
1977                is_type_only: false,
1978                is_side_effect_used: false,
1979                visibility: crate::source::VisibilityTag::None,
1980                expected_unused_reason: None,
1981                span: oxc_span::Span::empty(0),
1982                references: vec![],
1983                members: vec![],
1984            },
1985            fallow_graph::graph::ExportSymbol {
1986                name: crate::source::ExportName::Named("MyType".into()),
1987                is_type_only: true,
1988                is_side_effect_used: false,
1989                visibility: crate::source::VisibilityTag::None,
1990                expected_unused_reason: None,
1991                span: oxc_span::Span::empty(0),
1992                references: vec![],
1993                members: vec![],
1994            },
1995        ];
1996
1997        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
1998            rustc_hash::FxHashMap::default();
1999        unused_map.insert(path, 2);
2000
2001        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2002        assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
2003    }
2004
2005    #[test]
2006    fn dead_code_ratio_all_type_only_exports() {
2007        let unused_files = rustc_hash::FxHashSet::default();
2008        let path = std::path::Path::new("/src/types.ts");
2009
2010        let exports = vec![fallow_graph::graph::ExportSymbol {
2011            name: crate::source::ExportName::Named("Foo".into()),
2012            is_type_only: true,
2013            is_side_effect_used: false,
2014            visibility: crate::source::VisibilityTag::None,
2015            expected_unused_reason: None,
2016            span: oxc_span::Span::empty(0),
2017            references: vec![],
2018            members: vec![],
2019        }];
2020        let unused_map = rustc_hash::FxHashMap::default();
2021
2022        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2023        assert!((ratio).abs() < f64::EPSILON);
2024    }
2025
2026    #[test]
2027    fn aggregate_complexity_empty_module() {
2028        let module = crate::source::ModuleInfo {
2029            file_id: crate::discover::FileId(0),
2030            exports: vec![],
2031            imports: vec![],
2032            re_exports: vec![],
2033            dynamic_imports: vec![],
2034            dynamic_import_patterns: vec![],
2035            require_calls: vec![],
2036            package_path_references: Box::default(),
2037            member_accesses: vec![],
2038            semantic_facts: Box::default(),
2039            whole_object_uses: Box::default(),
2040            has_cjs_exports: false,
2041            has_angular_component_template_url: false,
2042            content_hash: 0,
2043            suppressions: vec![],
2044            unknown_suppression_kinds: vec![],
2045            unused_import_bindings: vec![],
2046            type_referenced_import_bindings: vec![],
2047            value_referenced_import_bindings: vec![],
2048            line_offsets: vec![],
2049            complexity: vec![],
2050            flag_uses: vec![],
2051            class_heritage: vec![],
2052            exported_factory_returns: Box::default(),
2053            exported_factory_return_object_shapes: Box::default(),
2054            type_member_types: Box::default(),
2055            injection_tokens: vec![],
2056            local_type_declarations: Vec::new(),
2057            public_signature_type_references: Vec::new(),
2058            namespace_object_aliases: Vec::new(),
2059            iconify_prefixes: Vec::new(),
2060            iconify_icon_names: Vec::new(),
2061            auto_import_candidates: Vec::new(),
2062            directives: Vec::new(),
2063            client_only_dynamic_import_spans: Vec::new(),
2064            security_sinks: Vec::new(),
2065            security_sinks_skipped: 0,
2066            security_unresolved_callee_sites: Vec::new(),
2067            tainted_bindings: Vec::new(),
2068            sanitized_sink_args: Vec::new(),
2069            security_control_sites: Vec::new(),
2070            callee_uses: Vec::new(),
2071            misplaced_directives: Vec::new(),
2072            inline_server_action_exports: Vec::new(),
2073            di_key_sites: Vec::new(),
2074            has_dynamic_provide: false,
2075            referenced_import_bindings: Vec::new(),
2076            component_props: Vec::new(),
2077            has_props_attrs_fallthrough: false,
2078            has_define_expose: false,
2079            has_define_model: false,
2080            has_unharvestable_props: false,
2081            component_emits: Vec::new(),
2082            angular_inputs: Vec::new(),
2083            angular_outputs: Vec::new(),
2084            has_unharvestable_emits: false,
2085            has_dynamic_emit: false,
2086            has_emit_whole_object_use: false,
2087            load_return_keys: Vec::new(),
2088            has_unharvestable_load: false,
2089            has_load_data_whole_use: false,
2090            has_page_data_store_whole_use: false,
2091            has_route_loader_data_whole_use: false,
2092            component_functions: Vec::new(),
2093            react_props: Vec::new(),
2094            hook_uses: Vec::new(),
2095            render_edges: Vec::new(),
2096            svelte_dispatched_events: Vec::new(),
2097            svelte_listened_events: Vec::new(),
2098            angular_component_selectors: Vec::new(),
2099            registered_custom_elements: Vec::new(),
2100            used_custom_element_tags: Vec::new(),
2101            angular_used_selectors: Vec::new(),
2102            angular_entry_component_refs: Vec::new(),
2103            has_dynamic_component_render: false,
2104            has_dynamic_dispatch: false,
2105        };
2106
2107        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2108        assert_eq!(cyc, 0);
2109        assert_eq!(cog, 0);
2110        assert_eq!(funcs, 0);
2111        assert_eq!(lines, 0);
2112    }
2113
2114    #[test]
2115    fn aggregate_complexity_single_function() {
2116        let module = crate::source::ModuleInfo {
2117            file_id: crate::discover::FileId(0),
2118            exports: vec![],
2119            imports: vec![],
2120            re_exports: vec![],
2121            dynamic_imports: vec![],
2122            dynamic_import_patterns: vec![],
2123            require_calls: vec![],
2124            package_path_references: Box::default(),
2125            member_accesses: vec![],
2126            semantic_facts: Box::default(),
2127            whole_object_uses: Box::default(),
2128            has_cjs_exports: false,
2129            has_angular_component_template_url: false,
2130            content_hash: 0,
2131            suppressions: vec![],
2132            unknown_suppression_kinds: vec![],
2133            unused_import_bindings: vec![],
2134            type_referenced_import_bindings: vec![],
2135            value_referenced_import_bindings: vec![],
2136            flag_uses: vec![],
2137            class_heritage: vec![],
2138            exported_factory_returns: Box::default(),
2139            exported_factory_return_object_shapes: Box::default(),
2140            type_member_types: Box::default(),
2141            injection_tokens: vec![],
2142            local_type_declarations: Vec::new(),
2143            public_signature_type_references: Vec::new(),
2144            namespace_object_aliases: Vec::new(),
2145            iconify_prefixes: Vec::new(),
2146            iconify_icon_names: Vec::new(),
2147            auto_import_candidates: Vec::new(),
2148            directives: Vec::new(),
2149            client_only_dynamic_import_spans: Vec::new(),
2150            security_sinks: Vec::new(),
2151            security_sinks_skipped: 0,
2152            security_unresolved_callee_sites: Vec::new(),
2153            tainted_bindings: Vec::new(),
2154            sanitized_sink_args: Vec::new(),
2155            security_control_sites: Vec::new(),
2156            callee_uses: Vec::new(),
2157            misplaced_directives: Vec::new(),
2158            inline_server_action_exports: Vec::new(),
2159            di_key_sites: Vec::new(),
2160            has_dynamic_provide: false,
2161            referenced_import_bindings: Vec::new(),
2162            component_props: Vec::new(),
2163            has_props_attrs_fallthrough: false,
2164            has_define_expose: false,
2165            has_define_model: false,
2166            has_unharvestable_props: false,
2167            component_emits: Vec::new(),
2168            angular_inputs: Vec::new(),
2169            angular_outputs: Vec::new(),
2170            has_unharvestable_emits: false,
2171            has_dynamic_emit: false,
2172            has_emit_whole_object_use: false,
2173            load_return_keys: Vec::new(),
2174            has_unharvestable_load: false,
2175            has_load_data_whole_use: false,
2176            has_page_data_store_whole_use: false,
2177            has_route_loader_data_whole_use: false,
2178            component_functions: Vec::new(),
2179            react_props: Vec::new(),
2180            hook_uses: Vec::new(),
2181            render_edges: Vec::new(),
2182            svelte_dispatched_events: Vec::new(),
2183            svelte_listened_events: Vec::new(),
2184            angular_component_selectors: Vec::new(),
2185            registered_custom_elements: Vec::new(),
2186            used_custom_element_tags: Vec::new(),
2187            angular_used_selectors: Vec::new(),
2188            angular_entry_component_refs: Vec::new(),
2189            has_dynamic_component_render: false,
2190            has_dynamic_dispatch: false,
2191            line_offsets: vec![0, 10, 20, 30, 40], // 5 lines
2192            complexity: vec![fallow_types::extract::FunctionComplexity {
2193                name: "doStuff".into(),
2194                line: 1,
2195                col: 0,
2196                cyclomatic: 7,
2197                cognitive: 4,
2198                line_count: 5,
2199                param_count: 0,
2200                react_hook_count: 0,
2201                react_jsx_max_depth: 0,
2202                react_prop_count: 0,
2203                source_hash: None,
2204                contributions: Vec::new(),
2205            }],
2206        };
2207
2208        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2209        assert_eq!(cyc, 7);
2210        assert_eq!(cog, 4);
2211        assert_eq!(funcs, 1);
2212        assert_eq!(lines, 5);
2213    }
2214
2215    #[test]
2216    #[expect(
2217        clippy::too_many_lines,
2218        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2219    )]
2220    fn aggregate_complexity_multiple_functions() {
2221        let module = crate::source::ModuleInfo {
2222            file_id: crate::discover::FileId(0),
2223            exports: vec![],
2224            imports: vec![],
2225            re_exports: vec![],
2226            dynamic_imports: vec![],
2227            dynamic_import_patterns: vec![],
2228            require_calls: vec![],
2229            package_path_references: Box::default(),
2230            member_accesses: vec![],
2231            semantic_facts: Box::default(),
2232            whole_object_uses: Box::default(),
2233            has_cjs_exports: false,
2234            has_angular_component_template_url: false,
2235            content_hash: 0,
2236            suppressions: vec![],
2237            unknown_suppression_kinds: vec![],
2238            unused_import_bindings: vec![],
2239            type_referenced_import_bindings: vec![],
2240            value_referenced_import_bindings: vec![],
2241            flag_uses: vec![],
2242            class_heritage: vec![],
2243            exported_factory_returns: Box::default(),
2244            exported_factory_return_object_shapes: Box::default(),
2245            type_member_types: Box::default(),
2246            injection_tokens: vec![],
2247            local_type_declarations: Vec::new(),
2248            public_signature_type_references: Vec::new(),
2249            namespace_object_aliases: Vec::new(),
2250            iconify_prefixes: Vec::new(),
2251            iconify_icon_names: Vec::new(),
2252            auto_import_candidates: Vec::new(),
2253            directives: Vec::new(),
2254            client_only_dynamic_import_spans: Vec::new(),
2255            security_sinks: Vec::new(),
2256            security_sinks_skipped: 0,
2257            security_unresolved_callee_sites: Vec::new(),
2258            tainted_bindings: Vec::new(),
2259            sanitized_sink_args: Vec::new(),
2260            security_control_sites: Vec::new(),
2261            callee_uses: Vec::new(),
2262            misplaced_directives: Vec::new(),
2263            inline_server_action_exports: Vec::new(),
2264            di_key_sites: Vec::new(),
2265            has_dynamic_provide: false,
2266            referenced_import_bindings: Vec::new(),
2267            component_props: Vec::new(),
2268            has_props_attrs_fallthrough: false,
2269            has_define_expose: false,
2270            has_define_model: false,
2271            has_unharvestable_props: false,
2272            component_emits: Vec::new(),
2273            angular_inputs: Vec::new(),
2274            angular_outputs: Vec::new(),
2275            has_unharvestable_emits: false,
2276            has_dynamic_emit: false,
2277            has_emit_whole_object_use: false,
2278            load_return_keys: Vec::new(),
2279            has_unharvestable_load: false,
2280            has_load_data_whole_use: false,
2281            has_page_data_store_whole_use: false,
2282            has_route_loader_data_whole_use: false,
2283            component_functions: Vec::new(),
2284            react_props: Vec::new(),
2285            hook_uses: Vec::new(),
2286            render_edges: Vec::new(),
2287            svelte_dispatched_events: Vec::new(),
2288            svelte_listened_events: Vec::new(),
2289            angular_component_selectors: Vec::new(),
2290            registered_custom_elements: Vec::new(),
2291            used_custom_element_tags: Vec::new(),
2292            angular_used_selectors: Vec::new(),
2293            angular_entry_component_refs: Vec::new(),
2294            has_dynamic_component_render: false,
2295            has_dynamic_dispatch: false,
2296            line_offsets: vec![0, 10, 20], // 3 lines
2297            complexity: vec![
2298                fallow_types::extract::FunctionComplexity {
2299                    name: "a".into(),
2300                    line: 1,
2301                    col: 0,
2302                    cyclomatic: 3,
2303                    cognitive: 2,
2304                    line_count: 1,
2305                    param_count: 0,
2306                    react_hook_count: 0,
2307                    react_jsx_max_depth: 0,
2308                    react_prop_count: 0,
2309                    source_hash: None,
2310                    contributions: Vec::new(),
2311                },
2312                fallow_types::extract::FunctionComplexity {
2313                    name: "b".into(),
2314                    line: 2,
2315                    col: 0,
2316                    cyclomatic: 5,
2317                    cognitive: 8,
2318                    line_count: 2,
2319                    param_count: 0,
2320                    react_hook_count: 0,
2321                    react_jsx_max_depth: 0,
2322                    react_prop_count: 0,
2323                    source_hash: None,
2324                    contributions: Vec::new(),
2325                },
2326            ],
2327        };
2328
2329        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2330        assert_eq!(cyc, 8);
2331        assert_eq!(cog, 10);
2332        assert_eq!(funcs, 2);
2333        assert_eq!(lines, 3);
2334    }
2335
2336    #[test]
2337    fn count_unused_exports_empty() {
2338        let exports: Vec<crate::results::UnusedExportFinding> = vec![];
2339        let map = count_unused_exports_by_path(&exports);
2340        assert!(map.is_empty());
2341    }
2342
2343    #[test]
2344    fn count_unused_exports_groups_by_path() {
2345        let exports = vec![
2346            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2347                path: std::path::PathBuf::from("/src/a.ts"),
2348                export_name: "foo".into(),
2349                is_type_only: false,
2350                line: 1,
2351                col: 0,
2352                span_start: 0,
2353                is_re_export: false,
2354            }),
2355            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2356                path: std::path::PathBuf::from("/src/a.ts"),
2357                export_name: "bar".into(),
2358                is_type_only: false,
2359                line: 5,
2360                col: 0,
2361                span_start: 40,
2362                is_re_export: false,
2363            }),
2364            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2365                path: std::path::PathBuf::from("/src/b.ts"),
2366                export_name: "baz".into(),
2367                is_type_only: false,
2368                line: 1,
2369                col: 0,
2370                span_start: 0,
2371                is_re_export: false,
2372            }),
2373        ];
2374        let map = count_unused_exports_by_path(&exports);
2375        assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
2376        assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
2377    }
2378
2379    #[test]
2380    fn dead_code_ratio_all_value_exports_unused() {
2381        let unused_files = rustc_hash::FxHashSet::default();
2382        let path = std::path::Path::new("/src/foo.ts");
2383
2384        let exports = vec![
2385            fallow_graph::graph::ExportSymbol {
2386                name: crate::source::ExportName::Named("a".into()),
2387                is_type_only: false,
2388                is_side_effect_used: false,
2389                visibility: crate::source::VisibilityTag::None,
2390                expected_unused_reason: None,
2391                span: oxc_span::Span::empty(0),
2392                references: vec![],
2393                members: vec![],
2394            },
2395            fallow_graph::graph::ExportSymbol {
2396                name: crate::source::ExportName::Named("b".into()),
2397                is_type_only: false,
2398                is_side_effect_used: false,
2399                visibility: crate::source::VisibilityTag::None,
2400                expected_unused_reason: None,
2401                span: oxc_span::Span::empty(0),
2402                references: vec![],
2403                members: vec![],
2404            },
2405        ];
2406
2407        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2408            rustc_hash::FxHashMap::default();
2409        unused_map.insert(path, 2);
2410
2411        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2412        assert!((ratio - 1.0).abs() < f64::EPSILON);
2413    }
2414
2415    #[test]
2416    fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
2417        let unused_files = rustc_hash::FxHashSet::default();
2418        let path = std::path::Path::new("/src/foo.ts");
2419
2420        let exports = vec![fallow_graph::graph::ExportSymbol {
2421            name: crate::source::ExportName::Named("a".into()),
2422            is_type_only: false,
2423            is_side_effect_used: false,
2424            visibility: crate::source::VisibilityTag::None,
2425            expected_unused_reason: None,
2426            span: oxc_span::Span::empty(0),
2427            references: vec![],
2428            members: vec![],
2429        }];
2430
2431        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2432            rustc_hash::FxHashMap::default();
2433        unused_map.insert(path, 5);
2434
2435        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2436        assert!((ratio - 1.0).abs() < f64::EPSILON);
2437    }
2438
2439    #[test]
2440    fn dead_code_ratio_no_unused_exports_for_path() {
2441        let unused_files = rustc_hash::FxHashSet::default();
2442        let path = std::path::Path::new("/src/clean.ts");
2443
2444        let exports = vec![fallow_graph::graph::ExportSymbol {
2445            name: crate::source::ExportName::Named("used".into()),
2446            is_type_only: false,
2447            is_side_effect_used: false,
2448            visibility: crate::source::VisibilityTag::None,
2449            expected_unused_reason: None,
2450            span: oxc_span::Span::empty(0),
2451            references: vec![],
2452            members: vec![],
2453        }];
2454
2455        let unused_map = rustc_hash::FxHashMap::default();
2456        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2457        assert!(ratio.abs() < f64::EPSILON);
2458    }
2459
2460    #[test]
2461    fn complexity_density_zero_cyclomatic_with_lines() {
2462        let result = compute_complexity_density(0, 100);
2463        assert!(result.abs() < f64::EPSILON);
2464    }
2465
2466    #[test]
2467    fn complexity_density_single_line() {
2468        let result = compute_complexity_density(1, 1);
2469        assert!((result - 1.0).abs() < f64::EPSILON);
2470    }
2471
2472    #[test]
2473    fn maintainability_only_complexity_penalty() {
2474        let result = compute_maintainability_index(3.0, 0.0, 0, 100);
2475        assert!((result - 10.0).abs() < f64::EPSILON);
2476    }
2477
2478    #[test]
2479    fn maintainability_only_dead_code_penalty() {
2480        let result = compute_maintainability_index(0.0, 0.5, 0, 100);
2481        assert!((result - 90.0).abs() < f64::EPSILON);
2482    }
2483
2484    #[test]
2485    fn maintainability_fan_out_one() {
2486        let result = compute_maintainability_index(0.0, 0.0, 1, 100);
2487        let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
2488        assert!((result - expected).abs() < 0.01);
2489    }
2490
2491    #[test]
2492    fn maintainability_all_penalties_maxed() {
2493        let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
2494        assert!(result.abs() < f64::EPSILON);
2495    }
2496
2497    #[test]
2498    fn count_unused_exports_single_file_single_export() {
2499        let exports = vec![crate::results::UnusedExportFinding::with_actions(
2500            crate::results::UnusedExport {
2501                path: std::path::PathBuf::from("/src/only.ts"),
2502                export_name: "lonely".into(),
2503                is_type_only: false,
2504                line: 1,
2505                col: 0,
2506                span_start: 0,
2507                is_re_export: false,
2508            },
2509        )];
2510        let map = count_unused_exports_by_path(&exports);
2511        assert_eq!(map.len(), 1);
2512        assert_eq!(
2513            map.get(std::path::Path::new("/src/only.ts")).copied(),
2514            Some(1)
2515        );
2516    }
2517
2518    /// Helper to build a minimal `ModuleGraph` from scratch.
2519    fn build_test_graph(
2520        files: &[crate::discover::DiscoveredFile],
2521        entry_point_paths: &[std::path::PathBuf],
2522        resolved_modules: &[fallow_graph::resolve::ResolvedModule],
2523    ) -> fallow_graph::graph::ModuleGraph {
2524        let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
2525            .iter()
2526            .map(|p| crate::discover::EntryPoint {
2527                path: p.clone(),
2528                source: crate::discover::EntryPointSource::PackageJsonMain,
2529            })
2530            .collect();
2531        fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
2532    }
2533
2534    /// Helper to create a `ModuleInfo` with given complexity and line count.
2535    fn make_module_info(
2536        file_id: u32,
2537        line_count: usize,
2538        functions: Vec<fallow_types::extract::FunctionComplexity>,
2539    ) -> crate::source::ModuleInfo {
2540        crate::source::ModuleInfo {
2541            file_id: crate::discover::FileId(file_id),
2542            exports: vec![],
2543            imports: vec![],
2544            re_exports: vec![],
2545            dynamic_imports: vec![],
2546            dynamic_import_patterns: vec![],
2547            require_calls: vec![],
2548            package_path_references: Box::default(),
2549            member_accesses: vec![],
2550            semantic_facts: Box::default(),
2551            whole_object_uses: Box::default(),
2552            has_cjs_exports: false,
2553            has_angular_component_template_url: false,
2554            content_hash: 0,
2555            suppressions: vec![],
2556            unknown_suppression_kinds: vec![],
2557            unused_import_bindings: vec![],
2558            type_referenced_import_bindings: vec![],
2559            value_referenced_import_bindings: vec![],
2560            line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
2561            complexity: functions,
2562            flag_uses: vec![],
2563            class_heritage: vec![],
2564            exported_factory_returns: Box::default(),
2565            exported_factory_return_object_shapes: Box::default(),
2566            type_member_types: Box::default(),
2567            injection_tokens: vec![],
2568            local_type_declarations: Vec::new(),
2569            public_signature_type_references: Vec::new(),
2570            namespace_object_aliases: Vec::new(),
2571            iconify_prefixes: Vec::new(),
2572            iconify_icon_names: Vec::new(),
2573            auto_import_candidates: Vec::new(),
2574            directives: Vec::new(),
2575            client_only_dynamic_import_spans: Vec::new(),
2576            security_sinks: Vec::new(),
2577            security_sinks_skipped: 0,
2578            security_unresolved_callee_sites: Vec::new(),
2579            tainted_bindings: Vec::new(),
2580            sanitized_sink_args: Vec::new(),
2581            security_control_sites: Vec::new(),
2582            callee_uses: Vec::new(),
2583            misplaced_directives: Vec::new(),
2584            inline_server_action_exports: Vec::new(),
2585            di_key_sites: Vec::new(),
2586            has_dynamic_provide: false,
2587            referenced_import_bindings: Vec::new(),
2588            component_props: Vec::new(),
2589            has_props_attrs_fallthrough: false,
2590            has_define_expose: false,
2591            has_define_model: false,
2592            has_unharvestable_props: false,
2593            component_emits: Vec::new(),
2594            angular_inputs: Vec::new(),
2595            angular_outputs: Vec::new(),
2596            has_unharvestable_emits: false,
2597            has_dynamic_emit: false,
2598            has_emit_whole_object_use: false,
2599            load_return_keys: Vec::new(),
2600            has_unharvestable_load: false,
2601            has_load_data_whole_use: false,
2602            has_page_data_store_whole_use: false,
2603            has_route_loader_data_whole_use: false,
2604            component_functions: Vec::new(),
2605            react_props: Vec::new(),
2606            hook_uses: Vec::new(),
2607            render_edges: Vec::new(),
2608            svelte_dispatched_events: Vec::new(),
2609            svelte_listened_events: Vec::new(),
2610            angular_component_selectors: Vec::new(),
2611            registered_custom_elements: Vec::new(),
2612            used_custom_element_tags: Vec::new(),
2613            angular_used_selectors: Vec::new(),
2614            angular_entry_component_refs: Vec::new(),
2615            has_dynamic_component_render: false,
2616            has_dynamic_dispatch: false,
2617        }
2618    }
2619
2620    fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
2621        FileHealthScore {
2622            path: std::path::PathBuf::from(path),
2623            fan_in: 0,
2624            fan_out: 0,
2625            dead_code_ratio: 0.0,
2626            complexity_density: 0.0,
2627            maintainability_index,
2628            total_cyclomatic: 0,
2629            total_cognitive: 0,
2630            function_count: 1,
2631            lines: 1,
2632            crap_max,
2633            crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
2634        }
2635    }
2636
2637    #[test]
2638    fn file_score_crap_concern_tracks_crap_risk_bands() {
2639        assert!((file_score_crap_concern(0.0) - 0.0).abs() < f64::EPSILON);
2640        assert!((file_score_crap_concern(15.0) - 45.0).abs() < f64::EPSILON);
2641        assert!((file_score_crap_concern(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
2642        assert!((file_score_crap_concern(100.0) - 100.0).abs() < f64::EPSILON);
2643        assert!((file_score_crap_concern(552.0) - 100.0).abs() < f64::EPSILON);
2644    }
2645
2646    #[test]
2647    fn file_score_concern_axis_labels_dominant_signal() {
2648        let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
2649        assert_eq!(
2650            file_score_concern_axis(&risk_driven),
2651            FileScoreConcern::Risk
2652        );
2653        assert_eq!(file_score_concern_axis(&risk_driven).label(), "risk");
2654
2655        let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
2656        assert_eq!(
2657            file_score_concern_axis(&structure_driven),
2658            FileScoreConcern::Structural
2659        );
2660        assert_eq!(
2661            file_score_concern_axis(&structure_driven).label(),
2662            "structure"
2663        );
2664
2665        let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
2666        assert_eq!(
2667            file_score_concern_axis(&no_risk),
2668            FileScoreConcern::Structural
2669        );
2670    }
2671
2672    #[test]
2673    fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
2674        let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
2675        let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
2676
2677        let mut scores = [low_mi_low_risk, higher_mi_high_risk];
2678        scores.sort_by(compare_file_score_triage);
2679
2680        assert_eq!(
2681            scores[0].path,
2682            std::path::Path::new("/src/higher-mi-high-risk.ts")
2683        );
2684        assert_eq!(
2685            scores[1].path,
2686            std::path::Path::new("/src/low-mi-low-risk.ts")
2687        );
2688    }
2689
2690    #[test]
2691    fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
2692        let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
2693        let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
2694
2695        let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
2696        scores.sort_by(compare_file_score_triage);
2697
2698        assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
2699        assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
2700    }
2701
2702    #[test]
2703    fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
2704        let mut scores = [
2705            make_file_score("/src/b.ts", 70.0, 1.0),
2706            make_file_score("/src/a.ts", 70.0, 1.0),
2707            make_file_score("/src/higher-crap.ts", 70.0, 2.0),
2708            make_file_score("/src/lower-concern.ts", 80.0, 1.0),
2709        ];
2710
2711        scores.sort_by(compare_file_score_triage);
2712
2713        let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
2714        assert_eq!(
2715            paths,
2716            vec![
2717                std::path::Path::new("/src/higher-crap.ts"),
2718                std::path::Path::new("/src/a.ts"),
2719                std::path::Path::new("/src/b.ts"),
2720                std::path::Path::new("/src/lower-concern.ts"),
2721            ]
2722        );
2723    }
2724
2725    #[test]
2726    fn compute_file_scores_empty_graph() {
2727        let files: Vec<crate::discover::DiscoveredFile> = vec![];
2728        let graph = build_test_graph(&files, &[], &[]);
2729        let modules: Vec<crate::source::ModuleInfo> = vec![];
2730        let file_paths = rustc_hash::FxHashMap::default();
2731
2732        let output = crate::results::DeadCodeAnalysisArtifacts {
2733            results: fallow_types::results::AnalysisResults::default(),
2734            timings: None,
2735            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2736            modules: None,
2737            files: None,
2738            script_used_packages: rustc_hash::FxHashSet::default(),
2739            file_hashes: rustc_hash::FxHashMap::default(),
2740        };
2741
2742        let result = compute_file_scores(
2743            &modules,
2744            &file_paths,
2745            None,
2746            output,
2747            None,
2748            std::path::Path::new("/project"),
2749        )
2750        .unwrap();
2751        assert!(result.scores.is_empty());
2752        assert!(result.circular_files.is_empty());
2753        assert!(result.top_complex_fns.is_empty());
2754        assert!(result.entry_points.is_empty());
2755        assert_eq!(result.analysis_counts.total_exports, 0);
2756        assert_eq!(result.analysis_counts.dead_files, 0);
2757    }
2758
2759    #[test]
2760    fn compute_file_scores_no_graph_returns_error() {
2761        let modules: Vec<crate::source::ModuleInfo> = vec![];
2762        let file_paths = rustc_hash::FxHashMap::default();
2763
2764        let output = crate::results::DeadCodeAnalysisArtifacts {
2765            results: fallow_types::results::AnalysisResults::default(),
2766            timings: None,
2767            graph: None,
2768            modules: None,
2769            files: None,
2770            script_used_packages: rustc_hash::FxHashSet::default(),
2771            file_hashes: rustc_hash::FxHashMap::default(),
2772        };
2773
2774        let result = compute_file_scores(
2775            &modules,
2776            &file_paths,
2777            None,
2778            output,
2779            None,
2780            std::path::Path::new("/project"),
2781        );
2782        assert!(result.is_err());
2783        match result {
2784            Err(msg) => assert_eq!(msg, "graph not available"),
2785            Ok(_) => panic!("expected error"),
2786        }
2787    }
2788
2789    #[test]
2790    fn compute_file_scores_single_file_with_function() {
2791        let path_a = std::path::PathBuf::from("/src/a.ts");
2792        let files = vec![crate::discover::DiscoveredFile {
2793            id: crate::discover::FileId(0),
2794            path: path_a.clone(),
2795            size_bytes: 100,
2796        }];
2797
2798        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2799            file_id: crate::discover::FileId(0),
2800            path: path_a.clone(),
2801            exports: vec![fallow_types::extract::ExportInfo {
2802                name: crate::source::ExportName::Named("foo".into()),
2803                local_name: None,
2804                is_type_only: false,
2805                visibility: crate::source::VisibilityTag::None,
2806                expected_unused_reason: None,
2807                span: oxc_span::Span::empty(0),
2808                members: vec![],
2809                is_side_effect_used: false,
2810                super_class: None,
2811            }],
2812            ..Default::default()
2813        }];
2814
2815        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2816
2817        let modules = vec![make_module_info(
2818            0,
2819            10,
2820            vec![fallow_types::extract::FunctionComplexity {
2821                name: "foo".into(),
2822                line: 1,
2823                col: 0,
2824                cyclomatic: 5,
2825                cognitive: 3,
2826                line_count: 10,
2827                param_count: 0,
2828                react_hook_count: 0,
2829                react_jsx_max_depth: 0,
2830                react_prop_count: 0,
2831                source_hash: None,
2832                contributions: Vec::new(),
2833            }],
2834        )];
2835
2836        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2837            rustc_hash::FxHashMap::default();
2838        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2839
2840        let output = crate::results::DeadCodeAnalysisArtifacts {
2841            results: fallow_types::results::AnalysisResults::default(),
2842            timings: None,
2843            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2844            modules: None,
2845            files: None,
2846            script_used_packages: rustc_hash::FxHashSet::default(),
2847            file_hashes: rustc_hash::FxHashMap::default(),
2848        };
2849
2850        let result = compute_file_scores(
2851            &modules,
2852            &file_paths,
2853            None,
2854            output,
2855            None,
2856            std::path::Path::new("/project"),
2857        )
2858        .unwrap();
2859        assert_eq!(result.scores.len(), 1);
2860
2861        let score = &result.scores[0];
2862        assert_eq!(score.path, path_a);
2863        assert_eq!(score.total_cyclomatic, 5);
2864        assert_eq!(score.total_cognitive, 3);
2865        assert_eq!(score.function_count, 1);
2866        assert_eq!(score.lines, 10);
2867        assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
2868        assert!(score.dead_code_ratio.abs() < f64::EPSILON);
2869        assert!(result.entry_points.contains(&path_a));
2870    }
2871
2872    #[test]
2873    fn compute_file_scores_excludes_barrel_files() {
2874        let path_a = std::path::PathBuf::from("/src/index.ts");
2875        let files = vec![crate::discover::DiscoveredFile {
2876            id: crate::discover::FileId(0),
2877            path: path_a.clone(),
2878            size_bytes: 50,
2879        }];
2880
2881        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2882            file_id: crate::discover::FileId(0),
2883            path: path_a.clone(),
2884            ..Default::default()
2885        }];
2886
2887        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2888
2889        let modules = vec![make_module_info(0, 5, vec![])];
2890
2891        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2892            rustc_hash::FxHashMap::default();
2893        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2894
2895        let output = crate::results::DeadCodeAnalysisArtifacts {
2896            results: fallow_types::results::AnalysisResults::default(),
2897            timings: None,
2898            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2899            modules: None,
2900            files: None,
2901            script_used_packages: rustc_hash::FxHashSet::default(),
2902            file_hashes: rustc_hash::FxHashMap::default(),
2903        };
2904
2905        let result = compute_file_scores(
2906            &modules,
2907            &file_paths,
2908            None,
2909            output,
2910            None,
2911            std::path::Path::new("/project"),
2912        )
2913        .unwrap();
2914        assert!(result.scores.is_empty());
2915    }
2916
2917    #[test]
2918    fn compute_file_scores_changed_since_filter() {
2919        let path_a = std::path::PathBuf::from("/src/a.ts");
2920        let path_b = std::path::PathBuf::from("/src/b.ts");
2921        let files = vec![
2922            crate::discover::DiscoveredFile {
2923                id: crate::discover::FileId(0),
2924                path: path_a.clone(),
2925                size_bytes: 100,
2926            },
2927            crate::discover::DiscoveredFile {
2928                id: crate::discover::FileId(1),
2929                path: path_b.clone(),
2930                size_bytes: 100,
2931            },
2932        ];
2933
2934        let resolved_modules = vec![
2935            fallow_graph::resolve::ResolvedModule {
2936                file_id: crate::discover::FileId(0),
2937                path: path_a,
2938                ..Default::default()
2939            },
2940            fallow_graph::resolve::ResolvedModule {
2941                file_id: crate::discover::FileId(1),
2942                path: path_b.clone(),
2943                ..Default::default()
2944            },
2945        ];
2946
2947        let graph = build_test_graph(&files, &[], &resolved_modules);
2948
2949        let modules = vec![
2950            make_module_info(
2951                0,
2952                10,
2953                vec![fallow_types::extract::FunctionComplexity {
2954                    name: "fn_a".into(),
2955                    line: 1,
2956                    col: 0,
2957                    cyclomatic: 2,
2958                    cognitive: 1,
2959                    line_count: 10,
2960                    param_count: 0,
2961                    react_hook_count: 0,
2962                    react_jsx_max_depth: 0,
2963                    react_prop_count: 0,
2964                    source_hash: None,
2965                    contributions: Vec::new(),
2966                }],
2967            ),
2968            make_module_info(
2969                1,
2970                10,
2971                vec![fallow_types::extract::FunctionComplexity {
2972                    name: "fn_b".into(),
2973                    line: 1,
2974                    col: 0,
2975                    cyclomatic: 3,
2976                    cognitive: 2,
2977                    line_count: 10,
2978                    param_count: 0,
2979                    react_hook_count: 0,
2980                    react_jsx_max_depth: 0,
2981                    react_prop_count: 0,
2982                    source_hash: None,
2983                    contributions: Vec::new(),
2984                }],
2985            ),
2986        ];
2987
2988        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2989            rustc_hash::FxHashMap::default();
2990        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2991        file_paths.insert(crate::discover::FileId(1), &files[1].path);
2992
2993        let path_b_check = std::path::PathBuf::from("/src/b.ts");
2994        let mut changed = rustc_hash::FxHashSet::default();
2995        changed.insert(path_b);
2996
2997        let output = crate::results::DeadCodeAnalysisArtifacts {
2998            results: fallow_types::results::AnalysisResults::default(),
2999            timings: None,
3000            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3001            modules: None,
3002            files: None,
3003            script_used_packages: rustc_hash::FxHashSet::default(),
3004            file_hashes: rustc_hash::FxHashMap::default(),
3005        };
3006
3007        let result = compute_file_scores(
3008            &modules,
3009            &file_paths,
3010            Some(&changed),
3011            output,
3012            None,
3013            std::path::Path::new("/project"),
3014        )
3015        .unwrap();
3016        assert_eq!(result.scores.len(), 1);
3017        assert_eq!(result.scores[0].path, path_b_check);
3018    }
3019
3020    #[test]
3021    fn compute_file_scores_sorted_by_triage_concern() {
3022        let path_a = std::path::PathBuf::from("/src/a.ts");
3023        let path_b = std::path::PathBuf::from("/src/b.ts");
3024        let files = vec![
3025            crate::discover::DiscoveredFile {
3026                id: crate::discover::FileId(0),
3027                path: path_a.clone(),
3028                size_bytes: 100,
3029            },
3030            crate::discover::DiscoveredFile {
3031                id: crate::discover::FileId(1),
3032                path: path_b.clone(),
3033                size_bytes: 100,
3034            },
3035        ];
3036
3037        let resolved_modules = vec![
3038            fallow_graph::resolve::ResolvedModule {
3039                file_id: crate::discover::FileId(0),
3040                path: path_a.clone(),
3041                ..Default::default()
3042            },
3043            fallow_graph::resolve::ResolvedModule {
3044                file_id: crate::discover::FileId(1),
3045                path: path_b,
3046                ..Default::default()
3047            },
3048        ];
3049
3050        let graph = build_test_graph(&files, &[], &resolved_modules);
3051
3052        let modules = vec![
3053            make_module_info(
3054                0,
3055                10,
3056                vec![fallow_types::extract::FunctionComplexity {
3057                    name: "complex_fn".into(),
3058                    line: 1,
3059                    col: 0,
3060                    cyclomatic: 30,
3061                    cognitive: 20,
3062                    line_count: 10,
3063                    param_count: 0,
3064                    react_hook_count: 0,
3065                    react_jsx_max_depth: 0,
3066                    react_prop_count: 0,
3067                    source_hash: None,
3068                    contributions: Vec::new(),
3069                }],
3070            ),
3071            make_module_info(
3072                1,
3073                100,
3074                vec![fallow_types::extract::FunctionComplexity {
3075                    name: "simple_fn".into(),
3076                    line: 1,
3077                    col: 0,
3078                    cyclomatic: 1,
3079                    cognitive: 0,
3080                    line_count: 100,
3081                    param_count: 0,
3082                    react_hook_count: 0,
3083                    react_jsx_max_depth: 0,
3084                    react_prop_count: 0,
3085                    source_hash: None,
3086                    contributions: Vec::new(),
3087                }],
3088            ),
3089        ];
3090
3091        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3092            rustc_hash::FxHashMap::default();
3093        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3094        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3095
3096        let output = crate::results::DeadCodeAnalysisArtifacts {
3097            results: fallow_types::results::AnalysisResults::default(),
3098            timings: None,
3099            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3100            modules: None,
3101            files: None,
3102            script_used_packages: rustc_hash::FxHashSet::default(),
3103            file_hashes: rustc_hash::FxHashMap::default(),
3104        };
3105
3106        let result = compute_file_scores(
3107            &modules,
3108            &file_paths,
3109            None,
3110            output,
3111            None,
3112            std::path::Path::new("/project"),
3113        )
3114        .unwrap();
3115        assert_eq!(result.scores.len(), 2);
3116        assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
3117        assert_eq!(result.scores[0].path, path_a);
3118    }
3119
3120    #[test]
3121    fn compute_file_scores_with_unused_file_populates_evidence() {
3122        let path_a = std::path::PathBuf::from("/src/unused.ts");
3123        let files = vec![crate::discover::DiscoveredFile {
3124            id: crate::discover::FileId(0),
3125            path: path_a.clone(),
3126            size_bytes: 100,
3127        }];
3128
3129        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3130            file_id: crate::discover::FileId(0),
3131            path: path_a.clone(),
3132            exports: vec![fallow_types::extract::ExportInfo {
3133                name: crate::source::ExportName::Named("orphan".into()),
3134                local_name: None,
3135                is_type_only: false,
3136                visibility: crate::source::VisibilityTag::None,
3137                expected_unused_reason: None,
3138                span: oxc_span::Span::empty(0),
3139                members: vec![],
3140                is_side_effect_used: false,
3141                super_class: None,
3142            }],
3143            ..Default::default()
3144        }];
3145
3146        let graph = build_test_graph(&files, &[], &resolved_modules);
3147
3148        let modules = vec![make_module_info(
3149            0,
3150            10,
3151            vec![fallow_types::extract::FunctionComplexity {
3152                name: "orphan".into(),
3153                line: 1,
3154                col: 0,
3155                cyclomatic: 1,
3156                cognitive: 0,
3157                line_count: 10,
3158                param_count: 0,
3159                react_hook_count: 0,
3160                react_jsx_max_depth: 0,
3161                react_prop_count: 0,
3162                source_hash: None,
3163                contributions: Vec::new(),
3164            }],
3165        )];
3166
3167        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3168            rustc_hash::FxHashMap::default();
3169        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3170
3171        let mut results = fallow_types::results::AnalysisResults::default();
3172        results.unused_files.push(
3173            fallow_types::output_dead_code::UnusedFileFinding::with_actions(
3174                fallow_types::results::UnusedFile {
3175                    path: path_a.clone(),
3176                },
3177            ),
3178        );
3179
3180        let output = crate::results::DeadCodeAnalysisArtifacts {
3181            results,
3182            timings: None,
3183            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3184            modules: None,
3185            files: None,
3186            script_used_packages: rustc_hash::FxHashSet::default(),
3187            file_hashes: rustc_hash::FxHashMap::default(),
3188        };
3189
3190        let result = compute_file_scores(
3191            &modules,
3192            &file_paths,
3193            None,
3194            output,
3195            None,
3196            std::path::Path::new("/project"),
3197        )
3198        .unwrap();
3199        assert_eq!(result.scores.len(), 1);
3200        assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
3201        assert!(result.unused_export_names.contains_key(&path_a));
3202        let names = &result.unused_export_names[&path_a];
3203        assert_eq!(names, &["orphan"]);
3204        assert_eq!(result.analysis_counts.dead_files, 1);
3205    }
3206
3207    #[test]
3208    #[expect(
3209        clippy::too_many_lines,
3210        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3211    )]
3212    fn compute_file_scores_tracks_top_complex_functions() {
3213        let path_a = std::path::PathBuf::from("/src/complex.ts");
3214        let files = vec![crate::discover::DiscoveredFile {
3215            id: crate::discover::FileId(0),
3216            path: path_a.clone(),
3217            size_bytes: 500,
3218        }];
3219
3220        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3221            file_id: crate::discover::FileId(0),
3222            path: path_a.clone(),
3223            ..Default::default()
3224        }];
3225
3226        let graph = build_test_graph(&files, &[], &resolved_modules);
3227
3228        let modules = vec![make_module_info(
3229            0,
3230            50,
3231            vec![
3232                fallow_types::extract::FunctionComplexity {
3233                    name: "high".into(),
3234                    line: 1,
3235                    col: 0,
3236                    cyclomatic: 10,
3237                    cognitive: 20,
3238                    line_count: 10,
3239                    param_count: 0,
3240                    react_hook_count: 0,
3241                    react_jsx_max_depth: 0,
3242                    react_prop_count: 0,
3243                    source_hash: None,
3244                    contributions: Vec::new(),
3245                },
3246                fallow_types::extract::FunctionComplexity {
3247                    name: "medium".into(),
3248                    line: 11,
3249                    col: 0,
3250                    cyclomatic: 5,
3251                    cognitive: 10,
3252                    line_count: 10,
3253                    param_count: 0,
3254                    react_hook_count: 0,
3255                    react_jsx_max_depth: 0,
3256                    react_prop_count: 0,
3257                    source_hash: None,
3258                    contributions: Vec::new(),
3259                },
3260                fallow_types::extract::FunctionComplexity {
3261                    name: "low".into(),
3262                    line: 21,
3263                    col: 0,
3264                    cyclomatic: 2,
3265                    cognitive: 5,
3266                    line_count: 10,
3267                    param_count: 0,
3268                    react_hook_count: 0,
3269                    react_jsx_max_depth: 0,
3270                    react_prop_count: 0,
3271                    source_hash: None,
3272                    contributions: Vec::new(),
3273                },
3274                fallow_types::extract::FunctionComplexity {
3275                    name: "trivial".into(),
3276                    line: 31,
3277                    col: 0,
3278                    cyclomatic: 1,
3279                    cognitive: 1,
3280                    line_count: 10,
3281                    param_count: 0,
3282                    react_hook_count: 0,
3283                    react_jsx_max_depth: 0,
3284                    react_prop_count: 0,
3285                    source_hash: None,
3286                    contributions: Vec::new(),
3287                },
3288            ],
3289        )];
3290
3291        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3292            rustc_hash::FxHashMap::default();
3293        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3294
3295        let output = crate::results::DeadCodeAnalysisArtifacts {
3296            results: fallow_types::results::AnalysisResults::default(),
3297            timings: None,
3298            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3299            modules: None,
3300            files: None,
3301            script_used_packages: rustc_hash::FxHashSet::default(),
3302            file_hashes: rustc_hash::FxHashMap::default(),
3303        };
3304
3305        let result = compute_file_scores(
3306            &modules,
3307            &file_paths,
3308            None,
3309            output,
3310            None,
3311            std::path::Path::new("/project"),
3312        )
3313        .unwrap();
3314        assert!(result.top_complex_fns.contains_key(&path_a));
3315        let top = &result.top_complex_fns[&path_a];
3316        assert_eq!(top.len(), 3);
3317        assert_eq!(top[0].0, "high");
3318        assert_eq!(top[0].2, 20);
3319        assert_eq!(top[1].0, "medium");
3320        assert_eq!(top[1].2, 10);
3321        assert_eq!(top[2].0, "low");
3322        assert_eq!(top[2].2, 5);
3323    }
3324
3325    #[test]
3326    #[expect(
3327        clippy::too_many_lines,
3328        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3329    )]
3330    fn compute_file_scores_with_circular_deps() {
3331        let path_a = std::path::PathBuf::from("/src/a.ts");
3332        let path_b = std::path::PathBuf::from("/src/b.ts");
3333        let files = vec![
3334            crate::discover::DiscoveredFile {
3335                id: crate::discover::FileId(0),
3336                path: path_a.clone(),
3337                size_bytes: 100,
3338            },
3339            crate::discover::DiscoveredFile {
3340                id: crate::discover::FileId(1),
3341                path: path_b.clone(),
3342                size_bytes: 100,
3343            },
3344        ];
3345
3346        let resolved_modules = vec![
3347            fallow_graph::resolve::ResolvedModule {
3348                file_id: crate::discover::FileId(0),
3349                path: path_a.clone(),
3350                ..Default::default()
3351            },
3352            fallow_graph::resolve::ResolvedModule {
3353                file_id: crate::discover::FileId(1),
3354                path: path_b.clone(),
3355                ..Default::default()
3356            },
3357        ];
3358
3359        let graph = build_test_graph(&files, &[], &resolved_modules);
3360
3361        let modules = vec![
3362            make_module_info(
3363                0,
3364                10,
3365                vec![fallow_types::extract::FunctionComplexity {
3366                    name: "fn_a".into(),
3367                    line: 1,
3368                    col: 0,
3369                    cyclomatic: 2,
3370                    cognitive: 1,
3371                    line_count: 10,
3372                    param_count: 0,
3373                    react_hook_count: 0,
3374                    react_jsx_max_depth: 0,
3375                    react_prop_count: 0,
3376                    source_hash: None,
3377                    contributions: Vec::new(),
3378                }],
3379            ),
3380            make_module_info(
3381                1,
3382                10,
3383                vec![fallow_types::extract::FunctionComplexity {
3384                    name: "fn_b".into(),
3385                    line: 1,
3386                    col: 0,
3387                    cyclomatic: 3,
3388                    cognitive: 2,
3389                    line_count: 10,
3390                    param_count: 0,
3391                    react_hook_count: 0,
3392                    react_jsx_max_depth: 0,
3393                    react_prop_count: 0,
3394                    source_hash: None,
3395                    contributions: Vec::new(),
3396                }],
3397            ),
3398        ];
3399
3400        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3401            rustc_hash::FxHashMap::default();
3402        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3403        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3404
3405        let mut results = fallow_types::results::AnalysisResults::default();
3406        results.circular_dependencies.push(
3407            fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
3408                fallow_types::results::CircularDependency {
3409                    files: vec![path_a.clone(), path_b.clone()],
3410                    length: 2,
3411                    line: 1,
3412                    col: 0,
3413                    edges: Vec::new(),
3414                    is_cross_package: false,
3415                },
3416            ),
3417        );
3418
3419        let output = crate::results::DeadCodeAnalysisArtifacts {
3420            results,
3421            timings: None,
3422            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3423            modules: None,
3424            files: None,
3425            script_used_packages: rustc_hash::FxHashSet::default(),
3426            file_hashes: rustc_hash::FxHashMap::default(),
3427        };
3428
3429        let result = compute_file_scores(
3430            &modules,
3431            &file_paths,
3432            None,
3433            output,
3434            None,
3435            std::path::Path::new("/project"),
3436        )
3437        .unwrap();
3438        assert!(result.circular_files.contains(&path_a));
3439        assert!(result.circular_files.contains(&path_b));
3440        assert!(result.cycle_members.contains_key(&path_a));
3441        assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
3442        assert!(result.cycle_members.contains_key(&path_b));
3443        assert_eq!(result.cycle_members[&path_b], vec![path_a]);
3444        assert_eq!(result.analysis_counts.circular_deps, 1);
3445    }
3446
3447    #[test]
3448    #[expect(
3449        clippy::too_many_lines,
3450        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3451    )]
3452    fn compute_file_scores_analysis_counts_unused_exports_and_types() {
3453        let path_a = std::path::PathBuf::from("/src/a.ts");
3454        let files = vec![crate::discover::DiscoveredFile {
3455            id: crate::discover::FileId(0),
3456            path: path_a.clone(),
3457            size_bytes: 100,
3458        }];
3459
3460        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3461            file_id: crate::discover::FileId(0),
3462            path: path_a.clone(),
3463            exports: vec![
3464                fallow_types::extract::ExportInfo {
3465                    name: crate::source::ExportName::Named("foo".into()),
3466                    local_name: None,
3467                    is_type_only: false,
3468                    visibility: crate::source::VisibilityTag::None,
3469                    expected_unused_reason: None,
3470                    span: oxc_span::Span::empty(0),
3471                    members: vec![],
3472                    is_side_effect_used: false,
3473                    super_class: None,
3474                },
3475                fallow_types::extract::ExportInfo {
3476                    name: crate::source::ExportName::Named("bar".into()),
3477                    local_name: None,
3478                    is_type_only: false,
3479                    visibility: crate::source::VisibilityTag::None,
3480                    expected_unused_reason: None,
3481                    span: oxc_span::Span::empty(0),
3482                    members: vec![],
3483                    is_side_effect_used: false,
3484                    super_class: None,
3485                },
3486            ],
3487            ..Default::default()
3488        }];
3489
3490        let graph = build_test_graph(&files, &[], &resolved_modules);
3491
3492        let mut module = make_module_info(
3493            0,
3494            10,
3495            vec![fallow_types::extract::FunctionComplexity {
3496                name: "fn_a".into(),
3497                line: 1,
3498                col: 0,
3499                cyclomatic: 1,
3500                cognitive: 0,
3501                line_count: 10,
3502                param_count: 0,
3503                react_hook_count: 0,
3504                react_jsx_max_depth: 0,
3505                react_prop_count: 0,
3506                source_hash: None,
3507                contributions: Vec::new(),
3508            }],
3509        );
3510        module.exports = vec![
3511            fallow_types::extract::ExportInfo {
3512                name: crate::source::ExportName::Named("foo".into()),
3513                local_name: None,
3514                is_type_only: false,
3515                visibility: crate::source::VisibilityTag::None,
3516                expected_unused_reason: None,
3517                span: oxc_span::Span::empty(0),
3518                members: vec![],
3519                is_side_effect_used: false,
3520                super_class: None,
3521            },
3522            fallow_types::extract::ExportInfo {
3523                name: crate::source::ExportName::Named("bar".into()),
3524                local_name: None,
3525                is_type_only: false,
3526                visibility: crate::source::VisibilityTag::None,
3527                expected_unused_reason: None,
3528                span: oxc_span::Span::empty(0),
3529                members: vec![],
3530                is_side_effect_used: false,
3531                super_class: None,
3532            },
3533        ];
3534        let modules = vec![module];
3535
3536        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3537            rustc_hash::FxHashMap::default();
3538        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3539
3540        let mut results = fallow_types::results::AnalysisResults::default();
3541        results.unused_exports.push(
3542            fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3543                fallow_types::results::UnusedExport {
3544                    path: path_a.clone(),
3545                    export_name: "foo".into(),
3546                    is_type_only: false,
3547                    line: 1,
3548                    col: 0,
3549                    span_start: 0,
3550                    is_re_export: false,
3551                },
3552            ),
3553        );
3554        results.unused_types.push(
3555            fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
3556                fallow_types::results::UnusedExport {
3557                    path: path_a,
3558                    export_name: "MyType".into(),
3559                    is_type_only: true,
3560                    line: 5,
3561                    col: 0,
3562                    span_start: 40,
3563                    is_re_export: false,
3564                },
3565            ),
3566        );
3567        results.unused_dependencies.push(
3568            fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
3569                fallow_types::results::UnusedDependency {
3570                    package_name: "lodash".into(),
3571                    location: fallow_types::results::DependencyLocation::Dependencies,
3572                    path: std::path::PathBuf::from("/package.json"),
3573                    line: 1,
3574                    used_in_workspaces: Vec::new(),
3575                },
3576            ),
3577        );
3578
3579        let output = crate::results::DeadCodeAnalysisArtifacts {
3580            results,
3581            timings: None,
3582            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3583            modules: None,
3584            files: None,
3585            script_used_packages: rustc_hash::FxHashSet::default(),
3586            file_hashes: rustc_hash::FxHashMap::default(),
3587        };
3588
3589        let result = compute_file_scores(
3590            &modules,
3591            &file_paths,
3592            None,
3593            output,
3594            None,
3595            std::path::Path::new("/project"),
3596        )
3597        .unwrap();
3598        assert_eq!(result.analysis_counts.total_exports, 2);
3599        assert_eq!(result.analysis_counts.dead_exports, 2);
3600        assert_eq!(result.analysis_counts.unused_deps, 1);
3601    }
3602
3603    /// Regression: total_exports must count graph modules, not extraction modules.
3604    #[test]
3605    #[expect(
3606        clippy::too_many_lines,
3607        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3608    )]
3609    fn total_exports_counts_graph_modules_not_extraction_modules() {
3610        let path_a = std::path::PathBuf::from("/src/a.ts");
3611        let files = vec![crate::discover::DiscoveredFile {
3612            id: crate::discover::FileId(0),
3613            path: path_a.clone(),
3614            size_bytes: 100,
3615        }];
3616
3617        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3618            file_id: crate::discover::FileId(0),
3619            path: path_a.clone(),
3620            exports: vec![
3621                fallow_types::extract::ExportInfo {
3622                    name: crate::source::ExportName::Named("foo".into()),
3623                    local_name: None,
3624                    is_type_only: false,
3625                    visibility: crate::source::VisibilityTag::None,
3626                    expected_unused_reason: None,
3627                    span: oxc_span::Span::empty(0),
3628                    members: vec![],
3629                    is_side_effect_used: false,
3630                    super_class: None,
3631                },
3632                fallow_types::extract::ExportInfo {
3633                    name: crate::source::ExportName::Named("bar".into()),
3634                    local_name: None,
3635                    is_type_only: false,
3636                    visibility: crate::source::VisibilityTag::None,
3637                    expected_unused_reason: None,
3638                    span: oxc_span::Span::empty(0),
3639                    members: vec![],
3640                    is_side_effect_used: false,
3641                    super_class: None,
3642                },
3643                fallow_types::extract::ExportInfo {
3644                    name: crate::source::ExportName::Named("baz".into()),
3645                    local_name: None,
3646                    is_type_only: false,
3647                    visibility: crate::source::VisibilityTag::None,
3648                    expected_unused_reason: None,
3649                    span: oxc_span::Span::new(0, 0),
3650                    members: vec![],
3651                    is_side_effect_used: false,
3652                    super_class: None,
3653                },
3654            ],
3655            ..Default::default()
3656        }];
3657
3658        let graph = build_test_graph(&files, &[], &resolved_modules);
3659
3660        let mut module = make_module_info(
3661            0,
3662            10,
3663            vec![fallow_types::extract::FunctionComplexity {
3664                name: "fn_a".into(),
3665                line: 1,
3666                col: 0,
3667                cyclomatic: 1,
3668                cognitive: 0,
3669                line_count: 10,
3670                param_count: 0,
3671                react_hook_count: 0,
3672                react_jsx_max_depth: 0,
3673                react_prop_count: 0,
3674                source_hash: None,
3675                contributions: Vec::new(),
3676            }],
3677        );
3678        module.exports = vec![
3679            fallow_types::extract::ExportInfo {
3680                name: crate::source::ExportName::Named("foo".into()),
3681                local_name: None,
3682                is_type_only: false,
3683                visibility: crate::source::VisibilityTag::None,
3684                expected_unused_reason: None,
3685                span: oxc_span::Span::empty(0),
3686                members: vec![],
3687                is_side_effect_used: false,
3688                super_class: None,
3689            },
3690            fallow_types::extract::ExportInfo {
3691                name: crate::source::ExportName::Named("bar".into()),
3692                local_name: None,
3693                is_type_only: false,
3694                visibility: crate::source::VisibilityTag::None,
3695                expected_unused_reason: None,
3696                span: oxc_span::Span::empty(0),
3697                members: vec![],
3698                is_side_effect_used: false,
3699                super_class: None,
3700            },
3701        ];
3702        let modules = vec![module];
3703
3704        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3705            rustc_hash::FxHashMap::default();
3706        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3707
3708        let mut results = fallow_types::results::AnalysisResults::default();
3709        for name in ["foo", "bar", "baz"] {
3710            results.unused_exports.push(
3711                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3712                    fallow_types::results::UnusedExport {
3713                        path: path_a.clone(),
3714                        export_name: name.into(),
3715                        is_type_only: false,
3716                        line: 1,
3717                        col: 0,
3718                        span_start: 0,
3719                        is_re_export: name == "baz",
3720                    },
3721                ),
3722            );
3723        }
3724
3725        let output = crate::results::DeadCodeAnalysisArtifacts {
3726            results,
3727            timings: None,
3728            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3729            modules: None,
3730            files: None,
3731            script_used_packages: rustc_hash::FxHashSet::default(),
3732            file_hashes: rustc_hash::FxHashMap::default(),
3733        };
3734
3735        let result = compute_file_scores(
3736            &modules,
3737            &file_paths,
3738            None,
3739            output,
3740            None,
3741            std::path::Path::new("/project"),
3742        )
3743        .unwrap();
3744        assert_eq!(result.analysis_counts.total_exports, 3);
3745        assert_eq!(result.analysis_counts.dead_exports, 3);
3746    }
3747
3748    #[test]
3749    fn compute_file_scores_module_not_in_file_paths_skipped() {
3750        let path_a = std::path::PathBuf::from("/src/a.ts");
3751        let files = vec![crate::discover::DiscoveredFile {
3752            id: crate::discover::FileId(0),
3753            path: path_a.clone(),
3754            size_bytes: 100,
3755        }];
3756
3757        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3758            file_id: crate::discover::FileId(0),
3759            path: path_a,
3760            ..Default::default()
3761        }];
3762
3763        let graph = build_test_graph(&files, &[], &resolved_modules);
3764
3765        let modules = vec![make_module_info(
3766            0,
3767            10,
3768            vec![fallow_types::extract::FunctionComplexity {
3769                name: "fn_a".into(),
3770                line: 1,
3771                col: 0,
3772                cyclomatic: 2,
3773                cognitive: 1,
3774                line_count: 10,
3775                param_count: 0,
3776                react_hook_count: 0,
3777                react_jsx_max_depth: 0,
3778                react_prop_count: 0,
3779                source_hash: None,
3780                contributions: Vec::new(),
3781            }],
3782        )];
3783
3784        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3785            rustc_hash::FxHashMap::default();
3786
3787        let output = crate::results::DeadCodeAnalysisArtifacts {
3788            results: fallow_types::results::AnalysisResults::default(),
3789            timings: None,
3790            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3791            modules: None,
3792            files: None,
3793            script_used_packages: rustc_hash::FxHashSet::default(),
3794            file_hashes: rustc_hash::FxHashMap::default(),
3795        };
3796
3797        let result = compute_file_scores(
3798            &modules,
3799            &file_paths,
3800            None,
3801            output,
3802            None,
3803            std::path::Path::new("/project"),
3804        )
3805        .unwrap();
3806        assert!(result.scores.is_empty());
3807    }
3808
3809    #[test]
3810    fn compute_file_scores_mi_rounded_to_one_decimal() {
3811        let path_a = std::path::PathBuf::from("/src/a.ts");
3812        let files = vec![crate::discover::DiscoveredFile {
3813            id: crate::discover::FileId(0),
3814            path: path_a.clone(),
3815            size_bytes: 100,
3816        }];
3817
3818        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3819            file_id: crate::discover::FileId(0),
3820            path: path_a.clone(),
3821            ..Default::default()
3822        }];
3823
3824        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
3825
3826        let modules = vec![make_module_info(
3827            0,
3828            100,
3829            vec![fallow_types::extract::FunctionComplexity {
3830                name: "fn".into(),
3831                line: 1,
3832                col: 0,
3833                cyclomatic: 7,
3834                cognitive: 3,
3835                line_count: 100,
3836                param_count: 0,
3837                react_hook_count: 0,
3838                react_jsx_max_depth: 0,
3839                react_prop_count: 0,
3840                source_hash: None,
3841                contributions: Vec::new(),
3842            }],
3843        )];
3844
3845        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3846            rustc_hash::FxHashMap::default();
3847        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3848
3849        let output = crate::results::DeadCodeAnalysisArtifacts {
3850            results: fallow_types::results::AnalysisResults::default(),
3851            timings: None,
3852            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3853            modules: None,
3854            files: None,
3855            script_used_packages: rustc_hash::FxHashSet::default(),
3856            file_hashes: rustc_hash::FxHashMap::default(),
3857        };
3858
3859        let result = compute_file_scores(
3860            &modules,
3861            &file_paths,
3862            None,
3863            output,
3864            None,
3865            std::path::Path::new("/project"),
3866        )
3867        .unwrap();
3868        let mi = result.scores[0].maintainability_index;
3869        let rounded = (mi * 10.0).round() / 10.0;
3870        assert!((mi - rounded).abs() < f64::EPSILON);
3871    }
3872
3873    #[test]
3874    fn compute_file_scores_value_export_counts_tracked() {
3875        let path_a = std::path::PathBuf::from("/src/a.ts");
3876        let files = vec![crate::discover::DiscoveredFile {
3877            id: crate::discover::FileId(0),
3878            path: path_a.clone(),
3879            size_bytes: 100,
3880        }];
3881
3882        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3883            file_id: crate::discover::FileId(0),
3884            path: path_a.clone(),
3885            exports: vec![
3886                fallow_types::extract::ExportInfo {
3887                    name: crate::source::ExportName::Named("a".into()),
3888                    local_name: None,
3889                    is_type_only: false,
3890                    visibility: crate::source::VisibilityTag::None,
3891                    expected_unused_reason: None,
3892                    span: oxc_span::Span::empty(0),
3893                    members: vec![],
3894                    is_side_effect_used: false,
3895                    super_class: None,
3896                },
3897                fallow_types::extract::ExportInfo {
3898                    name: crate::source::ExportName::Named("b".into()),
3899                    local_name: None,
3900                    is_type_only: false,
3901                    visibility: crate::source::VisibilityTag::None,
3902                    expected_unused_reason: None,
3903                    span: oxc_span::Span::empty(0),
3904                    members: vec![],
3905                    is_side_effect_used: false,
3906                    super_class: None,
3907                },
3908                fallow_types::extract::ExportInfo {
3909                    name: crate::source::ExportName::Named("T".into()),
3910                    local_name: None,
3911                    is_type_only: true,
3912                    visibility: crate::source::VisibilityTag::None,
3913                    expected_unused_reason: None,
3914                    span: oxc_span::Span::empty(0),
3915                    members: vec![],
3916                    is_side_effect_used: false,
3917                    super_class: None,
3918                },
3919            ],
3920            ..Default::default()
3921        }];
3922
3923        let graph = build_test_graph(&files, &[], &resolved_modules);
3924
3925        let modules = vec![make_module_info(
3926            0,
3927            10,
3928            vec![fallow_types::extract::FunctionComplexity {
3929                name: "fn_a".into(),
3930                line: 1,
3931                col: 0,
3932                cyclomatic: 2,
3933                cognitive: 1,
3934                line_count: 10,
3935                param_count: 0,
3936                react_hook_count: 0,
3937                react_jsx_max_depth: 0,
3938                react_prop_count: 0,
3939                source_hash: None,
3940                contributions: Vec::new(),
3941            }],
3942        )];
3943
3944        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3945            rustc_hash::FxHashMap::default();
3946        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3947
3948        let output = crate::results::DeadCodeAnalysisArtifacts {
3949            results: fallow_types::results::AnalysisResults::default(),
3950            timings: None,
3951            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3952            modules: None,
3953            files: None,
3954            script_used_packages: rustc_hash::FxHashSet::default(),
3955            file_hashes: rustc_hash::FxHashMap::default(),
3956        };
3957
3958        let result = compute_file_scores(
3959            &modules,
3960            &file_paths,
3961            None,
3962            output,
3963            None,
3964            std::path::Path::new("/project"),
3965        )
3966        .unwrap();
3967        assert_eq!(result.value_export_counts[&path_a], 2);
3968    }
3969
3970    #[test]
3971    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
3972        let path_a = std::path::PathBuf::from("/src/simple.ts");
3973        let files = vec![crate::discover::DiscoveredFile {
3974            id: crate::discover::FileId(0),
3975            path: path_a.clone(),
3976            size_bytes: 100,
3977        }];
3978
3979        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3980            file_id: crate::discover::FileId(0),
3981            path: path_a.clone(),
3982            ..Default::default()
3983        }];
3984
3985        let graph = build_test_graph(&files, &[], &resolved_modules);
3986
3987        let modules = vec![make_module_info(
3988            0,
3989            10,
3990            vec![fallow_types::extract::FunctionComplexity {
3991                name: "trivial".into(),
3992                line: 1,
3993                col: 0,
3994                cyclomatic: 1,
3995                cognitive: 0,
3996                line_count: 10,
3997                param_count: 0,
3998                react_hook_count: 0,
3999                react_jsx_max_depth: 0,
4000                react_prop_count: 0,
4001                source_hash: None,
4002                contributions: Vec::new(),
4003            }],
4004        )];
4005
4006        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4007            rustc_hash::FxHashMap::default();
4008        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4009
4010        let output = crate::results::DeadCodeAnalysisArtifacts {
4011            results: fallow_types::results::AnalysisResults::default(),
4012            timings: None,
4013            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4014            modules: None,
4015            files: None,
4016            script_used_packages: rustc_hash::FxHashSet::default(),
4017            file_hashes: rustc_hash::FxHashMap::default(),
4018        };
4019
4020        let result = compute_file_scores(
4021            &modules,
4022            &file_paths,
4023            None,
4024            output,
4025            None,
4026            std::path::Path::new("/project"),
4027        )
4028        .unwrap();
4029        assert!(!result.top_complex_fns.contains_key(&path_a));
4030    }
4031
4032    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
4033        fallow_types::extract::FunctionComplexity {
4034            name: "test_fn".into(),
4035            line: 1,
4036            col: 0,
4037            cyclomatic,
4038            cognitive: 0,
4039            line_count: 10,
4040            param_count: 0,
4041            react_hook_count: 0,
4042            react_jsx_max_depth: 0,
4043            react_prop_count: 0,
4044            source_hash: None,
4045            contributions: Vec::new(),
4046        }
4047    }
4048
4049    #[test]
4050    fn crap_scores_empty_complexity() {
4051        let (max, above) = compute_crap_scores_binary(&[], true);
4052        assert!((max).abs() < f64::EPSILON);
4053        assert_eq!(above, 0);
4054    }
4055
4056    #[test]
4057    fn crap_scores_test_reachable() {
4058        let funcs = vec![make_fn_complexity(5)];
4059        let (max, above) = compute_crap_scores_binary(&funcs, true);
4060        assert!((max - 5.0).abs() < f64::EPSILON);
4061        assert_eq!(above, 0);
4062    }
4063
4064    #[test]
4065    fn crap_scores_untested_at_threshold() {
4066        let funcs = vec![make_fn_complexity(5)];
4067        let (max, above) = compute_crap_scores_binary(&funcs, false);
4068        assert!((max - 30.0).abs() < f64::EPSILON);
4069        assert_eq!(above, 1);
4070    }
4071
4072    #[test]
4073    fn crap_scores_untested_above_threshold() {
4074        let funcs = vec![make_fn_complexity(6)];
4075        let (max, above) = compute_crap_scores_binary(&funcs, false);
4076        assert!((max - 42.0).abs() < f64::EPSILON);
4077        assert_eq!(above, 1);
4078    }
4079
4080    #[test]
4081    fn crap_scores_untested_below_threshold() {
4082        let funcs = vec![make_fn_complexity(4)];
4083        let (max, above) = compute_crap_scores_binary(&funcs, false);
4084        assert!((max - 20.0).abs() < f64::EPSILON);
4085        assert_eq!(above, 0);
4086    }
4087
4088    #[test]
4089    fn crap_scores_mixed_functions_untested() {
4090        let funcs = vec![
4091            make_fn_complexity(2),
4092            make_fn_complexity(5),
4093            make_fn_complexity(8),
4094        ];
4095        let (max, above) = compute_crap_scores_binary(&funcs, false);
4096        assert!((max - 72.0).abs() < f64::EPSILON);
4097        assert_eq!(above, 2);
4098    }
4099
4100    #[test]
4101    fn crap_formula_full_coverage() {
4102        let result = crap_formula(10.0, 100.0);
4103        assert!((result - 10.0).abs() < f64::EPSILON);
4104    }
4105
4106    #[test]
4107    fn crap_formula_zero_coverage() {
4108        let result = crap_formula(5.0, 0.0);
4109        assert!((result - 30.0).abs() < f64::EPSILON);
4110    }
4111
4112    #[test]
4113    fn crap_formula_partial_coverage() {
4114        let result = crap_formula(10.0, 50.0);
4115        assert!((result - 22.5).abs() < f64::EPSILON);
4116    }
4117
4118    #[test]
4119    fn crap_formula_high_coverage_low_complexity() {
4120        let result = crap_formula(2.0, 90.0);
4121        assert!((result - 2.004).abs() < 0.001);
4122    }
4123
4124    #[test]
4125    fn istanbul_crap_with_coverage_data() {
4126        let funcs = vec![make_fn_complexity(10)];
4127        let mut functions = rustc_hash::FxHashMap::default();
4128        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4129        let file_cov = IstanbulFileCoverage { functions };
4130        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4131        assert!((result.max_crap - 10.8).abs() < 0.1);
4132        assert_eq!(result.above_threshold, 0);
4133    }
4134
4135    #[test]
4136    fn istanbul_crap_falls_back_to_binary_when_no_match() {
4137        let funcs = vec![make_fn_complexity(6)];
4138        let file_cov = IstanbulFileCoverage {
4139            functions: rustc_hash::FxHashMap::default(),
4140        };
4141        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4142        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
4143        assert_eq!(result.above_threshold, 1);
4144    }
4145
4146    #[test]
4147    fn istanbul_crap_falls_back_to_binary_when_no_file_coverage() {
4148        let funcs = vec![make_fn_complexity(5)];
4149        let result = compute_crap_scores_istanbul(&funcs, None, true);
4150        assert!((result.max_crap - 5.0).abs() < f64::EPSILON);
4151        assert_eq!(result.above_threshold, 0);
4152    }
4153
4154    #[test]
4155    fn istanbul_crap_zero_coverage_matches_binary_untested() {
4156        let funcs = vec![make_fn_complexity(5)];
4157        let mut functions = rustc_hash::FxHashMap::default();
4158        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
4159        let file_cov = IstanbulFileCoverage { functions };
4160        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4161        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
4162        assert_eq!(result.above_threshold, 1);
4163    }
4164
4165    #[test]
4166    fn estimated_crap_direct_test_reference() {
4167        let funcs = vec![make_fn_complexity(10)];
4168        let mut refs = rustc_hash::FxHashSet::default();
4169        refs.insert("test_fn".to_string());
4170        let result = compute_crap_scores_estimated(
4171            &funcs,
4172            &refs,
4173            true,
4174            fallow_output::CoverageSource::Estimated,
4175        );
4176        let (max, above) = (result.max_crap, result.above_threshold);
4177        assert!((max - 10.3).abs() < 0.1);
4178        assert_eq!(above, 0);
4179    }
4180
4181    #[test]
4182    fn estimated_crap_indirect_test_reachable() {
4183        let funcs = vec![make_fn_complexity(10)];
4184        let refs = rustc_hash::FxHashSet::default();
4185        let result = compute_crap_scores_estimated(
4186            &funcs,
4187            &refs,
4188            true,
4189            fallow_output::CoverageSource::Estimated,
4190        );
4191        let (max, above) = (result.max_crap, result.above_threshold);
4192        assert!((max - 31.6).abs() < 0.1);
4193        assert_eq!(above, 1);
4194    }
4195
4196    #[test]
4197    fn estimated_crap_untested_file() {
4198        let funcs = vec![make_fn_complexity(5)];
4199        let refs = rustc_hash::FxHashSet::default();
4200        let result = compute_crap_scores_estimated(
4201            &funcs,
4202            &refs,
4203            false,
4204            fallow_output::CoverageSource::Estimated,
4205        );
4206        let (max, above) = (result.max_crap, result.above_threshold);
4207        assert!((max - 30.0).abs() < f64::EPSILON);
4208        assert_eq!(above, 1);
4209    }
4210
4211    #[test]
4212    fn estimated_crap_low_complexity_direct_ref() {
4213        let funcs = vec![make_fn_complexity(2)];
4214        let mut refs = rustc_hash::FxHashSet::default();
4215        refs.insert("test_fn".to_string());
4216        let result = compute_crap_scores_estimated(
4217            &funcs,
4218            &refs,
4219            true,
4220            fallow_output::CoverageSource::Estimated,
4221        );
4222        let (max, above) = (result.max_crap, result.above_threshold);
4223        assert!(max < 3.0);
4224        assert_eq!(above, 0);
4225    }
4226
4227    #[test]
4228    fn estimated_crap_empty() {
4229        let refs = rustc_hash::FxHashSet::default();
4230        let result = compute_crap_scores_estimated(
4231            &[],
4232            &refs,
4233            true,
4234            fallow_output::CoverageSource::Estimated,
4235        );
4236        let (max, above) = (result.max_crap, result.above_threshold);
4237        assert!((max).abs() < f64::EPSILON);
4238        assert_eq!(above, 0);
4239    }
4240
4241    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
4242        fallow_graph::graph::ExportSymbol {
4243            name: fallow_types::extract::ExportName::Named(name.into()),
4244            is_type_only,
4245            is_side_effect_used: false,
4246            visibility: crate::source::VisibilityTag::None,
4247            expected_unused_reason: None,
4248            span: oxc_span::Span::default(),
4249            references: vec![],
4250            members: vec![],
4251        }
4252    }
4253
4254    #[test]
4255    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
4256        let path = std::path::Path::new("src/types.ts");
4257        let exports = vec![
4258            make_export("MyInterface", true),
4259            make_export("MyType", true),
4260            make_export("myFunction", false),
4261        ];
4262        let unused_files = rustc_hash::FxHashSet::default();
4263        let mut unused_by_path = rustc_hash::FxHashMap::default();
4264        unused_by_path.insert(path, 1_usize); // 1 unused value export
4265
4266        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4267        assert!((ratio - 1.0).abs() < f64::EPSILON);
4268    }
4269
4270    #[test]
4271    fn dead_code_ratio_only_type_exports_returns_zero() {
4272        let path = std::path::Path::new("src/types.ts");
4273        let exports = vec![
4274            make_export("MyInterface", true),
4275            make_export("MyType", true),
4276        ];
4277        let unused_files = rustc_hash::FxHashSet::default();
4278        let unused_by_path = rustc_hash::FxHashMap::default();
4279
4280        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4281        assert!(ratio.abs() < f64::EPSILON);
4282    }
4283
4284    #[test]
4285    fn dead_code_ratio_mixed_exports_counts_only_values() {
4286        let path = std::path::Path::new("src/component.ts");
4287        let exports = vec![
4288            make_export("Props", true),
4289            make_export("State", true),
4290            make_export("Component", false),
4291            make_export("helper", false),
4292        ];
4293        let unused_files = rustc_hash::FxHashSet::default();
4294        let mut unused_by_path = rustc_hash::FxHashMap::default();
4295        unused_by_path.insert(path, 1_usize);
4296
4297        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4298        assert!((ratio - 0.5).abs() < f64::EPSILON);
4299    }
4300
4301    fn write_single_file_istanbul_fixture(
4302        coverage_path: &std::path::Path,
4303        source_path: &std::path::Path,
4304        fn_map: &serde_json::Value,
4305        function_hits: &serde_json::Value,
4306    ) {
4307        let mut root = serde_json::Map::new();
4308        root.insert(
4309            source_path.to_string_lossy().into_owned(),
4310            serde_json::json!({
4311                "path": source_path.to_string_lossy().into_owned(),
4312                "statementMap": {},
4313                "fnMap": fn_map,
4314                "branchMap": {},
4315                "s": {},
4316                "f": function_hits,
4317                "b": {}
4318            }),
4319        );
4320
4321        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
4322    }
4323
4324    #[test]
4325    fn resolve_relative_to_root_joins_relative_with_project_root() {
4326        let resolved = resolve_relative_to_root(
4327            std::path::Path::new("coverage/coverage-final.json"),
4328            Some(std::path::Path::new("/work/my-app")),
4329        );
4330        assert_eq!(
4331            resolved,
4332            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
4333        );
4334    }
4335
4336    #[test]
4337    fn resolve_relative_to_root_returns_absolute_unchanged() {
4338        let resolved = resolve_relative_to_root(
4339            std::path::Path::new("/tmp/coverage-final.json"),
4340            Some(std::path::Path::new("/work/my-app")),
4341        );
4342        assert_eq!(
4343            resolved,
4344            std::path::PathBuf::from("/tmp/coverage-final.json")
4345        );
4346    }
4347
4348    #[test]
4349    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
4350        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
4351        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
4352        assert_eq!(resolved, path);
4353    }
4354
4355    #[cfg(windows)]
4356    #[test]
4357    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
4358        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
4359        let resolved =
4360            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
4361        assert_eq!(resolved, path);
4362    }
4363
4364    #[test]
4365    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
4366        let resolved =
4367            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
4368        assert_eq!(
4369            resolved,
4370            std::path::PathBuf::from("coverage/coverage-final.json")
4371        );
4372    }
4373
4374    #[test]
4375    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
4376        let temp = tempfile::TempDir::new().unwrap();
4377        let source_path = temp.path().join("src/index.ts");
4378        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4379        std::fs::write(&source_path, "export function f(){}").unwrap();
4380
4381        let coverage_path = temp.path().join("coverage/coverage-final.json");
4382        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
4383        write_single_file_istanbul_fixture(
4384            &coverage_path,
4385            &source_path,
4386            &serde_json::json!({
4387                "0": {
4388                    "name": "f",
4389                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
4390                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
4391                }
4392            }),
4393            &serde_json::json!({ "0": 1 }),
4394        );
4395
4396        let coverage = load_istanbul_coverage(
4397            std::path::Path::new("coverage/coverage-final.json"),
4398            None,
4399            Some(temp.path()),
4400        )
4401        .expect("relative path must resolve against project_root");
4402        assert!(
4403            !coverage.files.is_empty(),
4404            "expected coverage to load via project_root resolution, got {} files",
4405            coverage.files.len()
4406        );
4407    }
4408
4409    #[test]
4410    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
4411        let temp = tempfile::TempDir::new().unwrap();
4412        let source_path = temp.path().join("src/service.ts");
4413        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4414        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
4415
4416        let coverage_path = temp.path().join("coverage-final.json");
4417        write_single_file_istanbul_fixture(
4418            &coverage_path,
4419            &source_path,
4420            &serde_json::json!({
4421                "0": {
4422                    "name": "(anonymous_0)",
4423                    "decl": {
4424                        "start": { "line": 5, "column": 2 },
4425                        "end": { "line": 5, "column": 13 }
4426                    },
4427                    "loc": {
4428                        "start": { "line": 5, "column": 14 },
4429                        "end": { "line": 11, "column": 3 }
4430                    }
4431                },
4432                "1": {
4433                    "name": "(anonymous_1)",
4434                    "decl": {
4435                        "start": { "line": 20, "column": 14 },
4436                        "end": { "line": 20, "column": 25 }
4437                    },
4438                    "loc": {
4439                        "start": { "line": 20, "column": 28 },
4440                        "end": { "line": 22, "column": 2 }
4441                    }
4442                }
4443            }),
4444            &serde_json::json!({
4445                "0": 1,
4446                "1": 0
4447            }),
4448        );
4449
4450        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4451        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4452        let file_coverage = coverage.get(&canonical_source).unwrap();
4453
4454        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
4455        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
4456    }
4457
4458    #[test]
4459    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
4460        let temp = tempfile::TempDir::new().unwrap();
4461        let source_path = temp.path().join("src/handler.ts");
4462        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4463        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
4464
4465        let coverage_path = temp.path().join("coverage-final.json");
4466        write_single_file_istanbul_fixture(
4467            &coverage_path,
4468            &source_path,
4469            &serde_json::json!({
4470                "0": {
4471                    "name": "handleClick",
4472                    "line": 40,
4473                    "decl": {
4474                        "start": { "line": 22, "column": 13 },
4475                        "end": { "line": 22, "column": 24 }
4476                    },
4477                    "loc": {
4478                        "start": { "line": 40, "column": 27 },
4479                        "end": { "line": 42, "column": 1 }
4480                    }
4481                }
4482            }),
4483            &serde_json::json!({
4484                "0": 1
4485            }),
4486        );
4487
4488        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4489        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4490        let file_coverage = coverage.get(&canonical_source).unwrap();
4491
4492        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
4493        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
4494    }
4495
4496    #[test]
4497    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
4498        let temp = tempfile::TempDir::new().unwrap();
4499        let source_path = temp.path().join("src/actor.ts");
4500        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4501        std::fs::write(
4502            &source_path,
4503            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
4504        )
4505        .unwrap();
4506
4507        let coverage_path = temp.path().join("coverage-final.json");
4508        write_single_file_istanbul_fixture(
4509            &coverage_path,
4510            &source_path,
4511            &serde_json::json!({
4512                "0": {
4513                    "name": "(anonymous_0)",
4514                    "line": 4,
4515                    "decl": {
4516                        "start": { "line": 1, "column": 28 },
4517                        "end": { "line": 4, "column": 26 }
4518                    },
4519                    "loc": {
4520                        "start": { "line": 4, "column": 27 },
4521                        "end": { "line": 6, "column": 1 }
4522                    }
4523                }
4524            }),
4525            &serde_json::json!({
4526                "0": 642
4527            }),
4528        );
4529
4530        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4531        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4532        let file_coverage = coverage.get(&canonical_source).unwrap();
4533
4534        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
4535    }
4536
4537    #[test]
4538    fn istanbul_lookup_exact_match() {
4539        let mut functions = rustc_hash::FxHashMap::default();
4540        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4541        let fc = IstanbulFileCoverage { functions };
4542        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
4543    }
4544
4545    #[test]
4546    fn istanbul_lookup_fuzzy_match_within_offset() {
4547        let mut functions = rustc_hash::FxHashMap::default();
4548        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4549        let fc = IstanbulFileCoverage { functions };
4550        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4551        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4552    }
4553
4554    #[test]
4555    fn istanbul_lookup_fuzzy_match_outside_offset() {
4556        let mut functions = rustc_hash::FxHashMap::default();
4557        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4558        let fc = IstanbulFileCoverage { functions };
4559        assert!(fc.lookup("handleClick", 13, 0).is_none());
4560    }
4561
4562    #[test]
4563    fn istanbul_lookup_name_mismatch() {
4564        let mut functions = rustc_hash::FxHashMap::default();
4565        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4566        let fc = IstanbulFileCoverage { functions };
4567        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
4568    }
4569
4570    #[test]
4571    fn istanbul_lookup_empty() {
4572        let fc = IstanbulFileCoverage {
4573            functions: rustc_hash::FxHashMap::default(),
4574        };
4575        assert!(fc.lookup("anything", 1, 0).is_none());
4576    }
4577
4578    #[test]
4579    fn istanbul_lookup_fuzzy_picks_closest() {
4580        let mut functions = rustc_hash::FxHashMap::default();
4581        functions.insert(("render".to_string(), 8, 0), 60.0);
4582        functions.insert(("render".to_string(), 12, 0), 90.0);
4583        let fc = IstanbulFileCoverage { functions };
4584        let result = fc.lookup("render", 10, 0);
4585        assert!(result.is_some());
4586        let pct = result.unwrap();
4587        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
4588    }
4589
4590    #[test]
4591    fn istanbul_lookup_anonymous_fallback_single_candidate() {
4592        let mut functions = rustc_hash::FxHashMap::default();
4593        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4594        let fc = IstanbulFileCoverage { functions };
4595        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4596        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4597    }
4598
4599    #[test]
4600    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
4601        let mut functions = rustc_hash::FxHashMap::default();
4602        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
4603        let fc = IstanbulFileCoverage { functions };
4604
4605        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
4606    }
4607
4608    #[test]
4609    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
4610        let mut functions = rustc_hash::FxHashMap::default();
4611        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4612        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
4613        let fc = IstanbulFileCoverage { functions };
4614        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4615    }
4616
4617    #[test]
4618    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
4619        let mut functions = rustc_hash::FxHashMap::default();
4620        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
4621        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
4622        let fc = IstanbulFileCoverage { functions };
4623        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
4624        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
4625    }
4626
4627    #[test]
4628    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
4629        let mut functions = rustc_hash::FxHashMap::default();
4630        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
4631        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
4632        let fc = IstanbulFileCoverage { functions };
4633        assert!(fc.lookup("myHandler", 28, 0).is_none());
4634    }
4635
4636    #[test]
4637    fn istanbul_lookup_anonymous_fallback_outside_offset() {
4638        let mut functions = rustc_hash::FxHashMap::default();
4639        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4640        let fc = IstanbulFileCoverage { functions };
4641        assert!(fc.lookup("myHandler", 31, 0).is_none());
4642    }
4643
4644    #[test]
4645    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
4646        let mut functions = rustc_hash::FxHashMap::default();
4647        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
4648        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
4649        let fc = IstanbulFileCoverage { functions };
4650        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
4651    }
4652
4653    #[test]
4654    fn build_test_refs_empty() {
4655        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
4656        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
4657        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
4658        assert!(refs.is_empty());
4659    }
4660
4661    #[test]
4662    fn build_test_refs_empty_inputs() {
4663        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
4664        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
4665        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
4666        assert!(refs.is_empty());
4667    }
4668
4669    #[test]
4670    fn istanbul_crap_empty_complexity() {
4671        let result = compute_crap_scores_istanbul(&[], None, false);
4672        assert!((result.max_crap).abs() < f64::EPSILON);
4673        assert_eq!(result.above_threshold, 0);
4674        assert_eq!(result.matched, 0);
4675        assert_eq!(result.total, 0);
4676    }
4677
4678    #[test]
4679    fn istanbul_crap_match_statistics() {
4680        let funcs = vec![make_fn_complexity(5), {
4681            let mut f = make_fn_complexity(3);
4682            f.name = "other_fn".into();
4683            f.line = 10;
4684            f
4685        }];
4686        let mut functions = rustc_hash::FxHashMap::default();
4687        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4688        let file_cov = IstanbulFileCoverage { functions };
4689        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), true);
4690        assert_eq!(result.matched, 1);
4691        assert_eq!(result.total, 2);
4692    }
4693
4694    #[test]
4695    fn estimated_crap_multiple_functions_mixed_coverage() {
4696        let funcs = vec![
4697            make_fn_complexity(10), // name "test_fn" line 1
4698            {
4699                let mut f = make_fn_complexity(3);
4700                f.name = "helper".into();
4701                f.line = 20;
4702                f
4703            },
4704        ];
4705        let mut refs = rustc_hash::FxHashSet::default();
4706        refs.insert("test_fn".to_string());
4707        let result = compute_crap_scores_estimated(
4708            &funcs,
4709            &refs,
4710            true,
4711            fallow_output::CoverageSource::Estimated,
4712        );
4713        let (max, above) = (result.max_crap, result.above_threshold);
4714        assert!(max > 10.0);
4715        assert_eq!(above, 0);
4716    }
4717
4718    #[test]
4719    fn binary_crap_test_reachable() {
4720        let funcs = vec![make_fn_complexity(10)];
4721        let (max, above) = compute_crap_scores_binary(&funcs, true);
4722        assert!((max - 10.0).abs() < f64::EPSILON);
4723        assert_eq!(above, 0);
4724    }
4725
4726    #[test]
4727    fn binary_crap_not_reachable() {
4728        let funcs = vec![make_fn_complexity(6)];
4729        let (max, above) = compute_crap_scores_binary(&funcs, false);
4730        assert!((max - 42.0).abs() < f64::EPSILON);
4731        assert_eq!(above, 1);
4732    }
4733
4734    #[test]
4735    fn binary_crap_threshold_boundary() {
4736        let funcs = vec![make_fn_complexity(5)];
4737        let (max, above) = compute_crap_scores_binary(&funcs, false);
4738        assert!((max - 30.0).abs() < f64::EPSILON);
4739        assert_eq!(above, 1);
4740    }
4741
4742    #[test]
4743    fn binary_crap_empty() {
4744        let (max, above) = compute_crap_scores_binary(&[], true);
4745        assert!((max).abs() < f64::EPSILON);
4746        assert_eq!(above, 0);
4747    }
4748
4749    #[test]
4750    fn binary_crap_multiple_functions() {
4751        let funcs = vec![make_fn_complexity(3), make_fn_complexity(8)];
4752        let (max, above) = compute_crap_scores_binary(&funcs, false);
4753        assert!((max - 72.0).abs() < f64::EPSILON);
4754        assert_eq!(above, 1);
4755    }
4756}