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