Skip to main content

fallow_engine/health/
scoring.rs

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