Skip to main content

fallow_engine/health/
scoring.rs

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