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    Structural,
1119    Risk,
1120}
1121
1122impl FileScoreConcern {
1123    /// Short lowercase label for the human file-scores table.
1124    pub const fn label(self) -> &'static str {
1125        match self {
1126            Self::Structural => "structure",
1127            Self::Risk => "risk",
1128        }
1129    }
1130}
1131
1132/// Classify which concern drove `score` to its rank. A file with no CRAP risk
1133/// is always `Structural`; otherwise the larger concern wins, with ties (and
1134/// the boundary where the two are equal) resolving to `Risk` because untested
1135/// complexity is the more urgent signal to act on.
1136pub fn file_score_concern_axis(score: &FileHealthScore) -> FileScoreConcern {
1137    if score.crap_max <= 0.0 {
1138        FileScoreConcern::Structural
1139    } else if file_score_crap_concern(score.crap_max) >= file_score_structural_concern(score) {
1140        FileScoreConcern::Risk
1141    } else {
1142        FileScoreConcern::Structural
1143    }
1144}
1145
1146fn compare_file_score_triage(a: &FileHealthScore, b: &FileHealthScore) -> std::cmp::Ordering {
1147    file_score_triage_concern(b)
1148        .total_cmp(&file_score_triage_concern(a))
1149        .then_with(|| b.crap_max.total_cmp(&a.crap_max))
1150        .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
1151        .then_with(|| a.path.cmp(&b.path))
1152}
1153
1154/// Compute per-file health scores using a pre-computed analysis output.
1155///
1156/// The caller provides an `AnalysisOutput` (with graph and dead code results)
1157/// so this function does not need to re-run the analysis pipeline. Complexity
1158/// density is derived from the already-parsed modules.
1159pub(super) fn compute_file_scores(
1160    modules: &[crate::source::ModuleInfo],
1161    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1162    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1163    analysis_output: crate::results::DeadCodeAnalysisArtifacts,
1164    istanbul_coverage: Option<&IstanbulCoverage>,
1165    root: &std::path::Path,
1166) -> Result<FileScoreOutput, String> {
1167    let retained_graph = analysis_output.graph.ok_or("graph not available")?;
1168    let test_coverage = retained_graph.static_test_coverage();
1169    let graph = retained_graph.as_graph();
1170    let results = &analysis_output.results;
1171
1172    let circular_files = collect_circular_files(results);
1173    let top_complex_fns = collect_top_complex_fns(modules, file_paths);
1174    let cycle_members = collect_cycle_members(results);
1175    let direct_callers = collect_direct_callers(graph, file_paths);
1176    let unused_export_names = collect_unused_export_names(results);
1177
1178    let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
1179        .unused_files
1180        .iter()
1181        .map(|f| f.file.path.as_path())
1182        .collect();
1183
1184    let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
1185
1186    let FileScoreCoverageSetup {
1187        module_by_id,
1188        coverage,
1189    } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
1190
1191    let template_inherit =
1192        build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
1193
1194    let mut acc = accumulate_file_scores(
1195        unused_export_names,
1196        &FileScoreLoopCtx {
1197            graph,
1198            test_coverage,
1199            file_paths,
1200            module_by_id: &module_by_id,
1201            unused_files: &unused_files,
1202            unused_exports_by_path: &unused_exports_by_path,
1203            template_inherit: &template_inherit,
1204            istanbul_coverage,
1205        },
1206    );
1207    acc.scores = finalize_file_score_list(acc.scores, changed_files);
1208
1209    Ok(build_file_score_output(FileScoreOutputParts {
1210        graph,
1211        file_paths,
1212        results,
1213        scores: acc.scores,
1214        coverage,
1215        circular_files,
1216        top_complex_fns,
1217        entry_points: acc.entry_points,
1218        value_export_counts: acc.value_export_counts,
1219        unused_export_names: acc.unused_export_names,
1220        cycle_members,
1221        direct_callers,
1222        istanbul_matched: acc.istanbul_matched,
1223        istanbul_total: acc.istanbul_total,
1224        per_function_crap: acc.per_function_crap,
1225        template_inherit,
1226    }))
1227}
1228
1229/// Read-only inputs threaded into the per-node file-score loop.
1230struct FileScoreLoopCtx<'a> {
1231    graph: &'a fallow_graph::graph::ModuleGraph,
1232    test_coverage: StaticTestCoverage<'a>,
1233    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1234    module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1235    unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
1236    unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
1237    template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1238    istanbul_coverage: Option<&'a IstanbulCoverage>,
1239}
1240
1241/// Mutable accumulators populated by the per-node file-score loop.
1242struct FileScoreAccumulator {
1243    scores: Vec<FileHealthScore>,
1244    entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
1245    value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
1246    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1247    per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1248    istanbul_matched: usize,
1249    istanbul_total: usize,
1250}
1251
1252impl FileScoreAccumulator {
1253    /// Empty accumulator with the score vector pre-sized to the module count.
1254    fn with_capacity(modules: usize) -> Self {
1255        FileScoreAccumulator {
1256            scores: Vec::with_capacity(modules),
1257            entry_points: rustc_hash::FxHashSet::default(),
1258            value_export_counts: rustc_hash::FxHashMap::default(),
1259            unused_export_names: rustc_hash::FxHashMap::default(),
1260            per_function_crap: rustc_hash::FxHashMap::default(),
1261            istanbul_matched: 0,
1262            istanbul_total: 0,
1263        }
1264    }
1265}
1266
1267/// Drive the per-node loop, returning an accumulator with one score per
1268/// analyzable file. `unused_export_names` seeds the accumulator's same field.
1269fn accumulate_file_scores(
1270    unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1271    ctx: &FileScoreLoopCtx<'_>,
1272) -> FileScoreAccumulator {
1273    let mut acc = FileScoreAccumulator {
1274        unused_export_names,
1275        ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
1276    };
1277    for node in &ctx.graph.modules {
1278        let Some(path) = ctx.file_paths.get(&node.file_id) else {
1279            continue;
1280        };
1281        record_entry_point(&mut acc.entry_points, node, path);
1282        let score = compute_one_file_score(&mut acc, ctx, node, path);
1283        acc.scores.push(score);
1284    }
1285    acc
1286}
1287
1288/// Apply the changed-file scope filter, drop zero-function barrels, and sort by
1289/// risk-aware triage concern.
1290fn finalize_file_score_list(
1291    mut scores: Vec<FileHealthScore>,
1292    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1293) -> Vec<FileHealthScore> {
1294    if let Some(changed) = changed_files {
1295        scores.retain(|s| changed.contains(&s.path));
1296    }
1297    scores.retain(|s| s.function_count > 0);
1298    scores.sort_by(compare_file_score_triage);
1299    scores
1300}
1301
1302/// Compute the `FileHealthScore` for one node and fold its side data into `acc`.
1303fn compute_one_file_score(
1304    acc: &mut FileScoreAccumulator,
1305    ctx: &FileScoreLoopCtx<'_>,
1306    node: &fallow_graph::graph::ModuleNode,
1307    path: &std::path::Path,
1308) -> FileHealthScore {
1309    let fan_in = ctx
1310        .graph
1311        .reverse_deps
1312        .get(node.file_id.0 as usize)
1313        .map_or(0, Vec::len);
1314    let fan_out = node.edge_range.len();
1315
1316    let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
1317        .module_by_id
1318        .get(&node.file_id)
1319        .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
1320
1321    let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
1322    let path_owned = path.to_path_buf();
1323    acc.value_export_counts
1324        .insert(path_owned.clone(), value_exports);
1325    record_unused_file_export_names(
1326        path_owned.as_path(),
1327        &node.exports,
1328        ctx.unused_files,
1329        &mut acc.unused_export_names,
1330    );
1331
1332    let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
1333        compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
1334
1335    let crap = compute_file_score_crap(
1336        node,
1337        ctx.module_by_id.get(&node.file_id).copied(),
1338        ctx.test_coverage,
1339        ctx.template_inherit.get(&node.file_id),
1340        ctx.istanbul_coverage,
1341        &path_owned,
1342    );
1343    acc.istanbul_matched += crap.istanbul_matched;
1344    acc.istanbul_total += crap.istanbul_total;
1345    record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
1346
1347    FileHealthScore {
1348        path: path_owned,
1349        fan_in,
1350        fan_out,
1351        dead_code_ratio: dead_code_ratio_rounded,
1352        complexity_density: complexity_density_rounded,
1353        maintainability_index: maintainability_index_rounded,
1354        total_cyclomatic,
1355        total_cognitive,
1356        function_count,
1357        lines,
1358        crap_max: crap.max,
1359        crap_above_threshold: crap.above_threshold,
1360    }
1361}
1362
1363/// Compute the rounded dead-code-ratio, complexity-density, and
1364/// maintainability-index metrics for one file.
1365fn compute_file_score_metrics(
1366    node: &fallow_graph::graph::ModuleNode,
1367    path: &std::path::Path,
1368    ctx: &FileScoreLoopCtx<'_>,
1369    total_cyclomatic: u32,
1370    lines: u32,
1371    fan_out: usize,
1372) -> (f64, f64, f64) {
1373    let dead_code_ratio = compute_dead_code_ratio(
1374        path,
1375        &node.exports,
1376        ctx.unused_files,
1377        ctx.unused_exports_by_path,
1378    );
1379    let complexity_density = compute_complexity_density(total_cyclomatic, lines);
1380
1381    let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
1382    let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
1383
1384    let maintainability_index = compute_maintainability_index(
1385        complexity_density_rounded,
1386        dead_code_ratio_rounded,
1387        fan_out,
1388        lines,
1389    );
1390    (
1391        dead_code_ratio_rounded,
1392        complexity_density_rounded,
1393        (maintainability_index * 10.0).round() / 10.0,
1394    )
1395}
1396
1397fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
1398    let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
1399    let unused_deps = parts.results.unused_dependencies.len()
1400        + parts.results.unused_dev_dependencies.len()
1401        + parts.results.unused_optional_dependencies.len();
1402    let analysis_snapshot =
1403        build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
1404    let analysis_counts =
1405        build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
1406    let template_inherit_provenance =
1407        build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
1408
1409    FileScoreOutput {
1410        scores: parts.scores,
1411        coverage: parts.coverage,
1412        circular_files: parts.circular_files,
1413        top_complex_fns: parts.top_complex_fns,
1414        entry_points: parts.entry_points,
1415        value_export_counts: parts.value_export_counts,
1416        unused_export_names: parts.unused_export_names,
1417        cycle_members: parts.cycle_members,
1418        direct_callers: parts.direct_callers,
1419        analysis_counts,
1420        prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
1421        render_fan_in: parts.results.render_fan_in.clone(),
1422        analysis_snapshot,
1423        istanbul_matched: parts.istanbul_matched,
1424        istanbul_total: parts.istanbul_total,
1425        per_function_crap: parts.per_function_crap,
1426        template_inherit_provenance,
1427    }
1428}
1429
1430fn build_file_score_analysis_counts(
1431    results: &crate::results::AnalysisResults,
1432    total_exports: usize,
1433    unused_deps: usize,
1434) -> crate::vital_signs::AnalysisCounts {
1435    crate::vital_signs::AnalysisCounts {
1436        total_exports,
1437        dead_files: results.unused_files.len(),
1438        dead_exports: results.unused_exports.len() + results.unused_types.len(),
1439        unused_deps,
1440        circular_deps: results.circular_dependencies.len(),
1441        total_deps: 0usize,
1442    }
1443}
1444
1445fn build_template_inherit_provenance(
1446    template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1447    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1448) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
1449    template_inherit
1450        .into_iter()
1451        .filter_map(|(file_id, ctx)| {
1452            file_paths
1453                .get(&file_id)
1454                .map(|path| ((**path).clone(), ctx.provenance_owner))
1455        })
1456        .collect()
1457}
1458
1459fn record_entry_point(
1460    entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
1461    node: &fallow_graph::graph::ModuleNode,
1462    path: &std::path::Path,
1463) {
1464    if node.is_entry_point() {
1465        entry_points.insert(path.to_path_buf());
1466    }
1467}
1468
1469fn record_unused_file_export_names(
1470    path: &std::path::Path,
1471    exports: &[fallow_graph::graph::ExportSymbol],
1472    unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
1473    unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1474) {
1475    if !unused_files.contains(path) || unused_export_names.contains_key(path) {
1476        return;
1477    }
1478
1479    let names: Vec<String> = exports
1480        .iter()
1481        .filter(|export| !export.is_type_only)
1482        .map(|export| export.name.to_string())
1483        .collect();
1484    if !names.is_empty() {
1485        unused_export_names.insert(path.to_path_buf(), names);
1486    }
1487}
1488
1489struct FileScoreCrap {
1490    max: f64,
1491    above_threshold: usize,
1492    per_function: Vec<PerFunctionCrap>,
1493    istanbul_matched: usize,
1494    istanbul_total: usize,
1495}
1496
1497impl FileScoreCrap {
1498    fn empty() -> Self {
1499        Self {
1500            max: 0.0,
1501            above_threshold: 0,
1502            per_function: Vec::new(),
1503            istanbul_matched: 0,
1504            istanbul_total: 0,
1505        }
1506    }
1507
1508    fn estimated(result: EstimatedCrapResult) -> Self {
1509        Self {
1510            max: result.max_crap,
1511            above_threshold: result.above_threshold,
1512            per_function: result.per_function,
1513            istanbul_matched: 0,
1514            istanbul_total: 0,
1515        }
1516    }
1517
1518    fn istanbul(result: IstanbulCrapResult) -> Self {
1519        Self {
1520            max: result.max_crap,
1521            above_threshold: result.above_threshold,
1522            per_function: result.per_function,
1523            istanbul_matched: result.matched,
1524            istanbul_total: result.total,
1525        }
1526    }
1527}
1528
1529fn compute_file_score_crap(
1530    node: &fallow_graph::graph::ModuleNode,
1531    module: Option<&crate::source::ModuleInfo>,
1532    test_coverage: StaticTestCoverage<'_>,
1533    template_inherit: Option<&TemplateInheritContext>,
1534    istanbul_coverage: Option<&IstanbulCoverage>,
1535    path: &std::path::Path,
1536) -> FileScoreCrap {
1537    let Some(module) = module else {
1538        return FileScoreCrap::empty();
1539    };
1540
1541    let is_coverage_suppressed = crate::suppress::is_file_suppressed(
1542        &module.suppressions,
1543        fallow_types::suppress::IssueKind::CoverageGaps,
1544    );
1545    let is_test_reachable = test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
1546    let resolution = resolve_crap_coverage(template_inherit, istanbul_coverage, path);
1547    match resolution {
1548        CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
1549            compute_template_inherited_crap(module, inherit_ctx)
1550        }
1551        CrapCoverageResolution::Istanbul { file_coverage } => {
1552            compute_istanbul_file_crap(module, file_coverage, is_test_reachable)
1553        }
1554        CrapCoverageResolution::StaticEstimated => {
1555            compute_static_file_crap(module, &node.exports, test_coverage, is_test_reachable)
1556        }
1557    }
1558}
1559
1560fn compute_template_inherited_crap(
1561    module: &crate::source::ModuleInfo,
1562    inherit_ctx: &TemplateInheritContext,
1563) -> FileScoreCrap {
1564    FileScoreCrap::estimated(compute_crap_scores_estimated(
1565        &module.complexity,
1566        &inherit_ctx.test_referenced_exports,
1567        inherit_ctx.is_test_reachable,
1568        fallow_output::CoverageSource::EstimatedComponentInherited,
1569    ))
1570}
1571
1572fn compute_istanbul_file_crap(
1573    module: &crate::source::ModuleInfo,
1574    file_coverage: Option<&IstanbulFileCoverage>,
1575    is_test_reachable: bool,
1576) -> FileScoreCrap {
1577    FileScoreCrap::istanbul(compute_crap_scores_istanbul(
1578        &module.complexity,
1579        file_coverage,
1580        is_test_reachable,
1581    ))
1582}
1583
1584fn compute_static_file_crap(
1585    module: &crate::source::ModuleInfo,
1586    exports: &[fallow_graph::graph::ExportSymbol],
1587    test_coverage: StaticTestCoverage<'_>,
1588    is_test_reachable: bool,
1589) -> FileScoreCrap {
1590    let test_refs = build_test_referenced_exports(exports, test_coverage);
1591    FileScoreCrap::estimated(compute_crap_scores_estimated(
1592        &module.complexity,
1593        &test_refs,
1594        is_test_reachable,
1595        fallow_output::CoverageSource::Estimated,
1596    ))
1597}
1598
1599fn record_per_function_crap(
1600    per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1601    path: &std::path::Path,
1602    per_function: Vec<PerFunctionCrap>,
1603) {
1604    if !per_function.is_empty() {
1605        per_function_crap.insert(path.to_path_buf(), per_function);
1606    }
1607}
1608
1609struct FileScoreCoverageSetup<'a> {
1610    module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1611    coverage: CoverageGapData,
1612}
1613
1614fn prepare_file_score_coverage_setup<'a>(
1615    modules: &'a [crate::source::ModuleInfo],
1616    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1617    results: &crate::results::AnalysisResults,
1618    graph: &fallow_graph::graph::ModuleGraph,
1619    test_coverage: StaticTestCoverage<'_>,
1620    root: &std::path::Path,
1621) -> FileScoreCoverageSetup<'a> {
1622    let module_by_id: rustc_hash::FxHashMap<_, _> =
1623        modules.iter().map(|m| (m.file_id, m)).collect();
1624    let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
1625        .unused_exports
1626        .iter()
1627        .map(|export| {
1628            (
1629                export.export.path.as_path(),
1630                export.export.export_name.clone(),
1631            )
1632        })
1633        .collect();
1634    let coverage = compute_coverage_gaps(
1635        graph,
1636        test_coverage,
1637        file_paths,
1638        &module_by_id,
1639        &unused_exports,
1640        root,
1641    );
1642    FileScoreCoverageSetup {
1643        module_by_id,
1644        coverage,
1645    }
1646}
1647
1648fn collect_circular_files(
1649    results: &crate::results::AnalysisResults,
1650) -> rustc_hash::FxHashSet<std::path::PathBuf> {
1651    results
1652        .circular_dependencies
1653        .iter()
1654        .flat_map(|c| c.cycle.files.iter().cloned())
1655        .collect()
1656}
1657
1658fn collect_top_complex_fns(
1659    modules: &[crate::source::ModuleInfo],
1660    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1661) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
1662    let mut top_complex_fns = rustc_hash::FxHashMap::default();
1663    for module in modules {
1664        if module.complexity.is_empty() {
1665            continue;
1666        }
1667        let Some(path) = file_paths.get(&module.file_id) else {
1668            continue;
1669        };
1670        let mut funcs: Vec<(String, u32, u16)> = module
1671            .complexity
1672            .iter()
1673            .map(|f| (f.name.clone(), f.line, f.cognitive))
1674            .collect();
1675        funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
1676        funcs.truncate(3);
1677        if funcs[0].2 > 0 {
1678            top_complex_fns.insert((*path).clone(), funcs);
1679        }
1680    }
1681    top_complex_fns
1682}
1683
1684fn collect_cycle_members(
1685    results: &crate::results::AnalysisResults,
1686) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
1687    let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
1688        rustc_hash::FxHashMap::default();
1689    for cycle in &results.circular_dependencies {
1690        for file in &cycle.cycle.files {
1691            let others: Vec<std::path::PathBuf> = cycle
1692                .cycle
1693                .files
1694                .iter()
1695                .filter(|f| *f != file)
1696                .cloned()
1697                .collect();
1698            cycle_members
1699                .entry(file.clone())
1700                .or_default()
1701                .extend(others);
1702        }
1703    }
1704    for members in cycle_members.values_mut() {
1705        members.sort();
1706        members.dedup();
1707    }
1708    cycle_members
1709}
1710
1711fn collect_unused_export_names(
1712    results: &crate::results::AnalysisResults,
1713) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
1714    let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
1715        rustc_hash::FxHashMap::default();
1716    for exp in &results.unused_exports {
1717        unused_export_names
1718            .entry(exp.export.path.clone())
1719            .or_default()
1720            .push(exp.export.export_name.clone());
1721    }
1722    unused_export_names
1723}
1724
1725fn build_analysis_counts_snapshot(
1726    graph: &fallow_graph::graph::ModuleGraph,
1727    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1728    results: &crate::results::AnalysisResults,
1729    unused_deps: usize,
1730) -> AnalysisCountsSnapshot {
1731    let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
1732        graph.modules.len(),
1733        rustc_hash::FxBuildHasher,
1734    );
1735    for module in &graph.modules {
1736        if let Some(path) = file_paths.get(&module.file_id) {
1737            module_export_counts.insert((*path).clone(), module.exports.len());
1738        }
1739    }
1740
1741    let mut unused_export_paths =
1742        Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
1743    unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
1744    unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
1745
1746    let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
1747    unused_dep_package_paths.extend(
1748        results
1749            .unused_dependencies
1750            .iter()
1751            .map(|d| d.dep.path.clone()),
1752    );
1753    unused_dep_package_paths.extend(
1754        results
1755            .unused_dev_dependencies
1756            .iter()
1757            .map(|d| d.dep.path.clone()),
1758    );
1759    unused_dep_package_paths.extend(
1760        results
1761            .unused_optional_dependencies
1762            .iter()
1763            .map(|d| d.dep.path.clone()),
1764    );
1765
1766    AnalysisCountsSnapshot {
1767        unused_file_paths: results
1768            .unused_files
1769            .iter()
1770            .map(|f| f.file.path.clone())
1771            .collect(),
1772        unused_export_paths,
1773        unused_dep_package_paths,
1774        circular_dep_groups: results
1775            .circular_dependencies
1776            .iter()
1777            .map(|c| c.cycle.files.clone())
1778            .collect(),
1779        module_export_counts,
1780    }
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785    use super::*;
1786
1787    #[test]
1788    fn maintainability_perfect_score() {
1789        assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
1790    }
1791
1792    #[test]
1793    fn crap_resolution_prefers_template_inheritance_over_istanbul() {
1794        let inherit_ctx = TemplateInheritContext {
1795            is_test_reachable: true,
1796            test_referenced_exports: rustc_hash::FxHashSet::default(),
1797            provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
1798        };
1799        let istanbul = IstanbulCoverage {
1800            files: rustc_hash::FxHashMap::default(),
1801        };
1802
1803        let resolution = resolve_crap_coverage(
1804            Some(&inherit_ctx),
1805            Some(&istanbul),
1806            std::path::Path::new("/project/src/app.component.html"),
1807        );
1808
1809        assert!(matches!(
1810            resolution,
1811            CrapCoverageResolution::TemplateInherited(_)
1812        ));
1813    }
1814
1815    #[test]
1816    fn crap_resolution_keeps_istanbul_when_file_is_missing() {
1817        let istanbul = IstanbulCoverage {
1818            files: rustc_hash::FxHashMap::default(),
1819        };
1820
1821        let resolution = resolve_crap_coverage(
1822            None,
1823            Some(&istanbul),
1824            std::path::Path::new("/project/src/missing.ts"),
1825        );
1826
1827        assert!(matches!(
1828            resolution,
1829            CrapCoverageResolution::Istanbul {
1830                file_coverage: None
1831            }
1832        ));
1833    }
1834
1835    #[test]
1836    fn maintainability_clamped_at_zero() {
1837        assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
1838    }
1839
1840    #[test]
1841    fn maintainability_formula_correct() {
1842        let result = compute_maintainability_index(0.5, 0.3, 10, 100);
1843        let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
1844        assert!((result - expected).abs() < 0.01);
1845    }
1846
1847    #[test]
1848    fn maintainability_dead_file_penalty() {
1849        let result = compute_maintainability_index(0.0, 1.0, 0, 100);
1850        assert!((result - 80.0).abs() < f64::EPSILON);
1851    }
1852
1853    #[test]
1854    fn maintainability_fan_out_is_logarithmic() {
1855        let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
1856        let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
1857        let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
1858
1859        assert!(result_10 > 90.0); // ~90.4
1860        assert!(result_100 > 84.0); // 85.0 (capped)
1861        assert!((result_100 - result_200).abs() < f64::EPSILON);
1862    }
1863
1864    #[test]
1865    fn maintainability_fan_out_capped_at_15() {
1866        let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
1867        assert!((result - 65.0).abs() < f64::EPSILON);
1868    }
1869
1870    #[test]
1871    fn maintainability_small_file_dampened() {
1872        let small = compute_maintainability_index(0.40, 0.0, 0, 5);
1873        assert!((small - 98.8).abs() < 0.01);
1874    }
1875
1876    #[test]
1877    fn maintainability_large_file_undampened() {
1878        let large = compute_maintainability_index(0.30, 0.0, 0, 192);
1879        assert!((large - 91.0).abs() < 0.01);
1880    }
1881
1882    #[test]
1883    fn maintainability_small_file_ranks_better_than_complex_large_file() {
1884        let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
1885        let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
1886        assert!(
1887            trivial > nightmare,
1888            "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
1889        );
1890    }
1891
1892    #[test]
1893    fn maintainability_at_dampening_boundary() {
1894        let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
1895        let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
1896        assert!((at_boundary - above_boundary).abs() < 0.01);
1897    }
1898
1899    #[test]
1900    fn maintainability_zero_lines_zero_density_penalty() {
1901        let result = compute_maintainability_index(5.0, 0.0, 0, 0);
1902        assert!((result - 100.0).abs() < f64::EPSILON);
1903    }
1904
1905    #[test]
1906    fn complexity_density_zero_lines() {
1907        assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
1908    }
1909
1910    #[test]
1911    fn complexity_density_normal() {
1912        let result = compute_complexity_density(10, 100);
1913        assert!((result - 0.1).abs() < f64::EPSILON);
1914    }
1915
1916    #[test]
1917    fn complexity_density_high() {
1918        let result = compute_complexity_density(50, 10);
1919        assert!((result - 5.0).abs() < f64::EPSILON);
1920    }
1921
1922    #[test]
1923    fn dead_code_ratio_no_exports() {
1924        let unused_files = rustc_hash::FxHashSet::default();
1925        let unused_map = rustc_hash::FxHashMap::default();
1926        let path = std::path::Path::new("/src/foo.ts");
1927        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
1928
1929        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
1930        assert!((ratio).abs() < f64::EPSILON);
1931    }
1932
1933    #[test]
1934    fn dead_code_ratio_all_unused_file() {
1935        let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
1936            rustc_hash::FxHashSet::default();
1937        let path = std::path::Path::new("/src/foo.ts");
1938        unused_files.insert(path);
1939        let unused_map = rustc_hash::FxHashMap::default();
1940        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
1941
1942        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
1943        assert!((ratio - 1.0).abs() < f64::EPSILON);
1944    }
1945
1946    #[test]
1947    fn dead_code_ratio_mix() {
1948        let unused_files = rustc_hash::FxHashSet::default();
1949        let path = std::path::Path::new("/src/foo.ts");
1950
1951        let exports = vec![
1952            fallow_graph::graph::ExportSymbol {
1953                name: crate::source::ExportName::Named("a".into()),
1954                is_type_only: false,
1955                is_side_effect_used: false,
1956                visibility: crate::source::VisibilityTag::None,
1957                expected_unused_reason: None,
1958                span: oxc_span::Span::empty(0),
1959                references: vec![],
1960                reference_paths: Vec::new(),
1961                members: vec![],
1962            },
1963            fallow_graph::graph::ExportSymbol {
1964                name: crate::source::ExportName::Named("b".into()),
1965                is_type_only: false,
1966                is_side_effect_used: false,
1967                visibility: crate::source::VisibilityTag::None,
1968                expected_unused_reason: None,
1969                span: oxc_span::Span::empty(0),
1970                references: vec![],
1971                reference_paths: Vec::new(),
1972                members: vec![],
1973            },
1974            fallow_graph::graph::ExportSymbol {
1975                name: crate::source::ExportName::Named("c".into()),
1976                is_type_only: false,
1977                is_side_effect_used: false,
1978                visibility: crate::source::VisibilityTag::None,
1979                expected_unused_reason: None,
1980                span: oxc_span::Span::empty(0),
1981                references: vec![],
1982                reference_paths: Vec::new(),
1983                members: vec![],
1984            },
1985            fallow_graph::graph::ExportSymbol {
1986                name: crate::source::ExportName::Named("MyType".into()),
1987                is_type_only: true,
1988                is_side_effect_used: false,
1989                visibility: crate::source::VisibilityTag::None,
1990                expected_unused_reason: None,
1991                span: oxc_span::Span::empty(0),
1992                references: vec![],
1993                reference_paths: Vec::new(),
1994                members: vec![],
1995            },
1996        ];
1997
1998        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
1999            rustc_hash::FxHashMap::default();
2000        unused_map.insert(path, 2);
2001
2002        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2003        assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
2004    }
2005
2006    #[test]
2007    fn dead_code_ratio_all_type_only_exports() {
2008        let unused_files = rustc_hash::FxHashSet::default();
2009        let path = std::path::Path::new("/src/types.ts");
2010
2011        let exports = vec![fallow_graph::graph::ExportSymbol {
2012            name: crate::source::ExportName::Named("Foo".into()),
2013            is_type_only: true,
2014            is_side_effect_used: false,
2015            visibility: crate::source::VisibilityTag::None,
2016            expected_unused_reason: None,
2017            span: oxc_span::Span::empty(0),
2018            references: vec![],
2019            reference_paths: Vec::new(),
2020            members: vec![],
2021        }];
2022        let unused_map = rustc_hash::FxHashMap::default();
2023
2024        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2025        assert!((ratio).abs() < f64::EPSILON);
2026    }
2027
2028    #[test]
2029    fn aggregate_complexity_empty_module() {
2030        let module = crate::source::ModuleInfo {
2031            file_id: crate::discover::FileId(0),
2032            exports: vec![],
2033            imports: vec![],
2034            re_exports: vec![],
2035            dynamic_imports: vec![],
2036            dynamic_import_patterns: vec![],
2037            require_calls: vec![],
2038            package_path_references: Box::default(),
2039            member_accesses: vec![],
2040            semantic_facts: Box::default(),
2041            whole_object_uses: Box::default(),
2042            has_cjs_exports: false,
2043            has_angular_component_template_url: false,
2044            content_hash: 0,
2045            suppressions: vec![],
2046            unknown_suppression_kinds: vec![],
2047            unused_import_bindings: vec![],
2048            type_referenced_import_bindings: vec![],
2049            value_referenced_import_bindings: vec![],
2050            line_offsets: vec![],
2051            complexity: vec![],
2052            flag_uses: vec![],
2053            class_heritage: vec![],
2054            exported_factory_returns: Box::default(),
2055            exported_factory_return_object_shapes: Box::default(),
2056            type_member_types: Box::default(),
2057            injection_tokens: vec![],
2058            local_type_declarations: Vec::new(),
2059            public_signature_type_references: Vec::new(),
2060            namespace_object_aliases: Vec::new(),
2061            iconify_prefixes: Vec::new(),
2062            iconify_icon_names: Vec::new(),
2063            auto_import_candidates: Vec::new(),
2064            directives: Vec::new(),
2065            client_only_dynamic_import_spans: Vec::new(),
2066            security_sinks: Vec::new(),
2067            security_sinks_skipped: 0,
2068            security_unresolved_callee_sites: Vec::new(),
2069            tainted_bindings: Vec::new(),
2070            sanitized_sink_args: Vec::new(),
2071            security_control_sites: Vec::new(),
2072            callee_uses: Vec::new(),
2073            misplaced_directives: Vec::new(),
2074            inline_server_action_exports: Vec::new(),
2075            di_key_sites: Vec::new(),
2076            has_dynamic_provide: false,
2077            referenced_import_bindings: Vec::new(),
2078            component_props: Vec::new(),
2079            has_props_attrs_fallthrough: false,
2080            has_define_expose: false,
2081            has_define_model: false,
2082            has_unharvestable_props: false,
2083            component_emits: Vec::new(),
2084            angular_inputs: Vec::new(),
2085            angular_outputs: Vec::new(),
2086            has_unharvestable_emits: false,
2087            has_dynamic_emit: false,
2088            has_emit_whole_object_use: false,
2089            load_return_keys: Vec::new(),
2090            has_unharvestable_load: false,
2091            has_load_data_whole_use: false,
2092            has_page_data_store_whole_use: false,
2093            has_route_loader_data_whole_use: false,
2094            component_functions: Vec::new(),
2095            react_props: Vec::new(),
2096            hook_uses: Vec::new(),
2097            render_edges: Vec::new(),
2098            svelte_dispatched_events: Vec::new(),
2099            svelte_listened_events: Vec::new(),
2100            angular_component_selectors: Vec::new(),
2101            registered_custom_elements: Vec::new(),
2102            used_custom_element_tags: Vec::new(),
2103            angular_used_selectors: Vec::new(),
2104            angular_entry_component_refs: Vec::new(),
2105            has_dynamic_component_render: false,
2106            has_dynamic_dispatch: false,
2107        };
2108
2109        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2110        assert_eq!(cyc, 0);
2111        assert_eq!(cog, 0);
2112        assert_eq!(funcs, 0);
2113        assert_eq!(lines, 0);
2114    }
2115
2116    #[test]
2117    fn aggregate_complexity_single_function() {
2118        let module = crate::source::ModuleInfo {
2119            file_id: crate::discover::FileId(0),
2120            exports: vec![],
2121            imports: vec![],
2122            re_exports: vec![],
2123            dynamic_imports: vec![],
2124            dynamic_import_patterns: vec![],
2125            require_calls: vec![],
2126            package_path_references: Box::default(),
2127            member_accesses: vec![],
2128            semantic_facts: Box::default(),
2129            whole_object_uses: Box::default(),
2130            has_cjs_exports: false,
2131            has_angular_component_template_url: false,
2132            content_hash: 0,
2133            suppressions: vec![],
2134            unknown_suppression_kinds: vec![],
2135            unused_import_bindings: vec![],
2136            type_referenced_import_bindings: vec![],
2137            value_referenced_import_bindings: vec![],
2138            flag_uses: vec![],
2139            class_heritage: vec![],
2140            exported_factory_returns: Box::default(),
2141            exported_factory_return_object_shapes: Box::default(),
2142            type_member_types: Box::default(),
2143            injection_tokens: vec![],
2144            local_type_declarations: Vec::new(),
2145            public_signature_type_references: Vec::new(),
2146            namespace_object_aliases: Vec::new(),
2147            iconify_prefixes: Vec::new(),
2148            iconify_icon_names: Vec::new(),
2149            auto_import_candidates: Vec::new(),
2150            directives: Vec::new(),
2151            client_only_dynamic_import_spans: Vec::new(),
2152            security_sinks: Vec::new(),
2153            security_sinks_skipped: 0,
2154            security_unresolved_callee_sites: Vec::new(),
2155            tainted_bindings: Vec::new(),
2156            sanitized_sink_args: Vec::new(),
2157            security_control_sites: Vec::new(),
2158            callee_uses: Vec::new(),
2159            misplaced_directives: Vec::new(),
2160            inline_server_action_exports: Vec::new(),
2161            di_key_sites: Vec::new(),
2162            has_dynamic_provide: false,
2163            referenced_import_bindings: Vec::new(),
2164            component_props: Vec::new(),
2165            has_props_attrs_fallthrough: false,
2166            has_define_expose: false,
2167            has_define_model: false,
2168            has_unharvestable_props: false,
2169            component_emits: Vec::new(),
2170            angular_inputs: Vec::new(),
2171            angular_outputs: Vec::new(),
2172            has_unharvestable_emits: false,
2173            has_dynamic_emit: false,
2174            has_emit_whole_object_use: false,
2175            load_return_keys: Vec::new(),
2176            has_unharvestable_load: false,
2177            has_load_data_whole_use: false,
2178            has_page_data_store_whole_use: false,
2179            has_route_loader_data_whole_use: false,
2180            component_functions: Vec::new(),
2181            react_props: Vec::new(),
2182            hook_uses: Vec::new(),
2183            render_edges: Vec::new(),
2184            svelte_dispatched_events: Vec::new(),
2185            svelte_listened_events: Vec::new(),
2186            angular_component_selectors: Vec::new(),
2187            registered_custom_elements: Vec::new(),
2188            used_custom_element_tags: Vec::new(),
2189            angular_used_selectors: Vec::new(),
2190            angular_entry_component_refs: Vec::new(),
2191            has_dynamic_component_render: false,
2192            has_dynamic_dispatch: false,
2193            line_offsets: vec![0, 10, 20, 30, 40], // 5 lines
2194            complexity: vec![fallow_types::extract::FunctionComplexity {
2195                name: "doStuff".into(),
2196                line: 1,
2197                col: 0,
2198                cyclomatic: 7,
2199                cognitive: 4,
2200                line_count: 5,
2201                param_count: 0,
2202                react_hook_count: 0,
2203                react_jsx_max_depth: 0,
2204                react_prop_count: 0,
2205                source_hash: None,
2206                contributions: Vec::new(),
2207            }],
2208        };
2209
2210        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2211        assert_eq!(cyc, 7);
2212        assert_eq!(cog, 4);
2213        assert_eq!(funcs, 1);
2214        assert_eq!(lines, 5);
2215    }
2216
2217    #[test]
2218    #[expect(
2219        clippy::too_many_lines,
2220        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
2221    )]
2222    fn aggregate_complexity_multiple_functions() {
2223        let module = crate::source::ModuleInfo {
2224            file_id: crate::discover::FileId(0),
2225            exports: vec![],
2226            imports: vec![],
2227            re_exports: vec![],
2228            dynamic_imports: vec![],
2229            dynamic_import_patterns: vec![],
2230            require_calls: vec![],
2231            package_path_references: Box::default(),
2232            member_accesses: vec![],
2233            semantic_facts: Box::default(),
2234            whole_object_uses: Box::default(),
2235            has_cjs_exports: false,
2236            has_angular_component_template_url: false,
2237            content_hash: 0,
2238            suppressions: vec![],
2239            unknown_suppression_kinds: vec![],
2240            unused_import_bindings: vec![],
2241            type_referenced_import_bindings: vec![],
2242            value_referenced_import_bindings: vec![],
2243            flag_uses: vec![],
2244            class_heritage: vec![],
2245            exported_factory_returns: Box::default(),
2246            exported_factory_return_object_shapes: Box::default(),
2247            type_member_types: Box::default(),
2248            injection_tokens: vec![],
2249            local_type_declarations: Vec::new(),
2250            public_signature_type_references: Vec::new(),
2251            namespace_object_aliases: Vec::new(),
2252            iconify_prefixes: Vec::new(),
2253            iconify_icon_names: Vec::new(),
2254            auto_import_candidates: Vec::new(),
2255            directives: Vec::new(),
2256            client_only_dynamic_import_spans: Vec::new(),
2257            security_sinks: Vec::new(),
2258            security_sinks_skipped: 0,
2259            security_unresolved_callee_sites: Vec::new(),
2260            tainted_bindings: Vec::new(),
2261            sanitized_sink_args: Vec::new(),
2262            security_control_sites: Vec::new(),
2263            callee_uses: Vec::new(),
2264            misplaced_directives: Vec::new(),
2265            inline_server_action_exports: Vec::new(),
2266            di_key_sites: Vec::new(),
2267            has_dynamic_provide: false,
2268            referenced_import_bindings: Vec::new(),
2269            component_props: Vec::new(),
2270            has_props_attrs_fallthrough: false,
2271            has_define_expose: false,
2272            has_define_model: false,
2273            has_unharvestable_props: false,
2274            component_emits: Vec::new(),
2275            angular_inputs: Vec::new(),
2276            angular_outputs: Vec::new(),
2277            has_unharvestable_emits: false,
2278            has_dynamic_emit: false,
2279            has_emit_whole_object_use: false,
2280            load_return_keys: Vec::new(),
2281            has_unharvestable_load: false,
2282            has_load_data_whole_use: false,
2283            has_page_data_store_whole_use: false,
2284            has_route_loader_data_whole_use: false,
2285            component_functions: Vec::new(),
2286            react_props: Vec::new(),
2287            hook_uses: Vec::new(),
2288            render_edges: Vec::new(),
2289            svelte_dispatched_events: Vec::new(),
2290            svelte_listened_events: Vec::new(),
2291            angular_component_selectors: Vec::new(),
2292            registered_custom_elements: Vec::new(),
2293            used_custom_element_tags: Vec::new(),
2294            angular_used_selectors: Vec::new(),
2295            angular_entry_component_refs: Vec::new(),
2296            has_dynamic_component_render: false,
2297            has_dynamic_dispatch: false,
2298            line_offsets: vec![0, 10, 20], // 3 lines
2299            complexity: vec![
2300                fallow_types::extract::FunctionComplexity {
2301                    name: "a".into(),
2302                    line: 1,
2303                    col: 0,
2304                    cyclomatic: 3,
2305                    cognitive: 2,
2306                    line_count: 1,
2307                    param_count: 0,
2308                    react_hook_count: 0,
2309                    react_jsx_max_depth: 0,
2310                    react_prop_count: 0,
2311                    source_hash: None,
2312                    contributions: Vec::new(),
2313                },
2314                fallow_types::extract::FunctionComplexity {
2315                    name: "b".into(),
2316                    line: 2,
2317                    col: 0,
2318                    cyclomatic: 5,
2319                    cognitive: 8,
2320                    line_count: 2,
2321                    param_count: 0,
2322                    react_hook_count: 0,
2323                    react_jsx_max_depth: 0,
2324                    react_prop_count: 0,
2325                    source_hash: None,
2326                    contributions: Vec::new(),
2327                },
2328            ],
2329        };
2330
2331        let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2332        assert_eq!(cyc, 8);
2333        assert_eq!(cog, 10);
2334        assert_eq!(funcs, 2);
2335        assert_eq!(lines, 3);
2336    }
2337
2338    #[test]
2339    fn count_unused_exports_empty() {
2340        let exports: Vec<crate::results::UnusedExportFinding> = vec![];
2341        let map = count_unused_exports_by_path(&exports);
2342        assert!(map.is_empty());
2343    }
2344
2345    #[test]
2346    fn count_unused_exports_groups_by_path() {
2347        let exports = vec![
2348            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2349                path: std::path::PathBuf::from("/src/a.ts"),
2350                export_name: "foo".into(),
2351                is_type_only: false,
2352                line: 1,
2353                col: 0,
2354                span_start: 0,
2355                is_re_export: false,
2356            }),
2357            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2358                path: std::path::PathBuf::from("/src/a.ts"),
2359                export_name: "bar".into(),
2360                is_type_only: false,
2361                line: 5,
2362                col: 0,
2363                span_start: 40,
2364                is_re_export: false,
2365            }),
2366            crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2367                path: std::path::PathBuf::from("/src/b.ts"),
2368                export_name: "baz".into(),
2369                is_type_only: false,
2370                line: 1,
2371                col: 0,
2372                span_start: 0,
2373                is_re_export: false,
2374            }),
2375        ];
2376        let map = count_unused_exports_by_path(&exports);
2377        assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
2378        assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
2379    }
2380
2381    #[test]
2382    fn dead_code_ratio_all_value_exports_unused() {
2383        let unused_files = rustc_hash::FxHashSet::default();
2384        let path = std::path::Path::new("/src/foo.ts");
2385
2386        let exports = vec![
2387            fallow_graph::graph::ExportSymbol {
2388                name: crate::source::ExportName::Named("a".into()),
2389                is_type_only: false,
2390                is_side_effect_used: false,
2391                visibility: crate::source::VisibilityTag::None,
2392                expected_unused_reason: None,
2393                span: oxc_span::Span::empty(0),
2394                references: vec![],
2395                reference_paths: Vec::new(),
2396                members: vec![],
2397            },
2398            fallow_graph::graph::ExportSymbol {
2399                name: crate::source::ExportName::Named("b".into()),
2400                is_type_only: false,
2401                is_side_effect_used: false,
2402                visibility: crate::source::VisibilityTag::None,
2403                expected_unused_reason: None,
2404                span: oxc_span::Span::empty(0),
2405                references: vec![],
2406                reference_paths: Vec::new(),
2407                members: vec![],
2408            },
2409        ];
2410
2411        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2412            rustc_hash::FxHashMap::default();
2413        unused_map.insert(path, 2);
2414
2415        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2416        assert!((ratio - 1.0).abs() < f64::EPSILON);
2417    }
2418
2419    #[test]
2420    fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
2421        let unused_files = rustc_hash::FxHashSet::default();
2422        let path = std::path::Path::new("/src/foo.ts");
2423
2424        let exports = vec![fallow_graph::graph::ExportSymbol {
2425            name: crate::source::ExportName::Named("a".into()),
2426            is_type_only: false,
2427            is_side_effect_used: false,
2428            visibility: crate::source::VisibilityTag::None,
2429            expected_unused_reason: None,
2430            span: oxc_span::Span::empty(0),
2431            references: vec![],
2432            reference_paths: Vec::new(),
2433            members: vec![],
2434        }];
2435
2436        let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2437            rustc_hash::FxHashMap::default();
2438        unused_map.insert(path, 5);
2439
2440        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2441        assert!((ratio - 1.0).abs() < f64::EPSILON);
2442    }
2443
2444    #[test]
2445    fn dead_code_ratio_no_unused_exports_for_path() {
2446        let unused_files = rustc_hash::FxHashSet::default();
2447        let path = std::path::Path::new("/src/clean.ts");
2448
2449        let exports = vec![fallow_graph::graph::ExportSymbol {
2450            name: crate::source::ExportName::Named("used".into()),
2451            is_type_only: false,
2452            is_side_effect_used: false,
2453            visibility: crate::source::VisibilityTag::None,
2454            expected_unused_reason: None,
2455            span: oxc_span::Span::empty(0),
2456            references: vec![],
2457            reference_paths: Vec::new(),
2458            members: vec![],
2459        }];
2460
2461        let unused_map = rustc_hash::FxHashMap::default();
2462        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2463        assert!(ratio.abs() < f64::EPSILON);
2464    }
2465
2466    #[test]
2467    fn complexity_density_zero_cyclomatic_with_lines() {
2468        let result = compute_complexity_density(0, 100);
2469        assert!(result.abs() < f64::EPSILON);
2470    }
2471
2472    #[test]
2473    fn complexity_density_single_line() {
2474        let result = compute_complexity_density(1, 1);
2475        assert!((result - 1.0).abs() < f64::EPSILON);
2476    }
2477
2478    #[test]
2479    fn maintainability_only_complexity_penalty() {
2480        let result = compute_maintainability_index(3.0, 0.0, 0, 100);
2481        assert!((result - 10.0).abs() < f64::EPSILON);
2482    }
2483
2484    #[test]
2485    fn maintainability_only_dead_code_penalty() {
2486        let result = compute_maintainability_index(0.0, 0.5, 0, 100);
2487        assert!((result - 90.0).abs() < f64::EPSILON);
2488    }
2489
2490    #[test]
2491    fn maintainability_fan_out_one() {
2492        let result = compute_maintainability_index(0.0, 0.0, 1, 100);
2493        let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
2494        assert!((result - expected).abs() < 0.01);
2495    }
2496
2497    #[test]
2498    fn maintainability_all_penalties_maxed() {
2499        let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
2500        assert!(result.abs() < f64::EPSILON);
2501    }
2502
2503    #[test]
2504    fn count_unused_exports_single_file_single_export() {
2505        let exports = vec![crate::results::UnusedExportFinding::with_actions(
2506            crate::results::UnusedExport {
2507                path: std::path::PathBuf::from("/src/only.ts"),
2508                export_name: "lonely".into(),
2509                is_type_only: false,
2510                line: 1,
2511                col: 0,
2512                span_start: 0,
2513                is_re_export: false,
2514            },
2515        )];
2516        let map = count_unused_exports_by_path(&exports);
2517        assert_eq!(map.len(), 1);
2518        assert_eq!(
2519            map.get(std::path::Path::new("/src/only.ts")).copied(),
2520            Some(1)
2521        );
2522    }
2523
2524    /// Helper to build a minimal `ModuleGraph` from scratch.
2525    fn build_test_graph(
2526        files: &[crate::discover::DiscoveredFile],
2527        entry_point_paths: &[std::path::PathBuf],
2528        resolved_modules: &[fallow_graph::resolve::ResolvedModule],
2529    ) -> fallow_graph::graph::ModuleGraph {
2530        let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
2531            .iter()
2532            .map(|p| crate::discover::EntryPoint {
2533                path: p.clone(),
2534                source: crate::discover::EntryPointSource::PackageJsonMain,
2535            })
2536            .collect();
2537        fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
2538    }
2539
2540    /// Helper to create a `ModuleInfo` with given complexity and line count.
2541    fn make_module_info(
2542        file_id: u32,
2543        line_count: usize,
2544        functions: Vec<fallow_types::extract::FunctionComplexity>,
2545    ) -> crate::source::ModuleInfo {
2546        crate::source::ModuleInfo {
2547            file_id: crate::discover::FileId(file_id),
2548            exports: vec![],
2549            imports: vec![],
2550            re_exports: vec![],
2551            dynamic_imports: vec![],
2552            dynamic_import_patterns: vec![],
2553            require_calls: vec![],
2554            package_path_references: Box::default(),
2555            member_accesses: vec![],
2556            semantic_facts: Box::default(),
2557            whole_object_uses: Box::default(),
2558            has_cjs_exports: false,
2559            has_angular_component_template_url: false,
2560            content_hash: 0,
2561            suppressions: vec![],
2562            unknown_suppression_kinds: vec![],
2563            unused_import_bindings: vec![],
2564            type_referenced_import_bindings: vec![],
2565            value_referenced_import_bindings: vec![],
2566            line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
2567            complexity: functions,
2568            flag_uses: vec![],
2569            class_heritage: vec![],
2570            exported_factory_returns: Box::default(),
2571            exported_factory_return_object_shapes: Box::default(),
2572            type_member_types: Box::default(),
2573            injection_tokens: vec![],
2574            local_type_declarations: Vec::new(),
2575            public_signature_type_references: Vec::new(),
2576            namespace_object_aliases: Vec::new(),
2577            iconify_prefixes: Vec::new(),
2578            iconify_icon_names: Vec::new(),
2579            auto_import_candidates: Vec::new(),
2580            directives: Vec::new(),
2581            client_only_dynamic_import_spans: Vec::new(),
2582            security_sinks: Vec::new(),
2583            security_sinks_skipped: 0,
2584            security_unresolved_callee_sites: Vec::new(),
2585            tainted_bindings: Vec::new(),
2586            sanitized_sink_args: Vec::new(),
2587            security_control_sites: Vec::new(),
2588            callee_uses: Vec::new(),
2589            misplaced_directives: Vec::new(),
2590            inline_server_action_exports: Vec::new(),
2591            di_key_sites: Vec::new(),
2592            has_dynamic_provide: false,
2593            referenced_import_bindings: Vec::new(),
2594            component_props: Vec::new(),
2595            has_props_attrs_fallthrough: false,
2596            has_define_expose: false,
2597            has_define_model: false,
2598            has_unharvestable_props: false,
2599            component_emits: Vec::new(),
2600            angular_inputs: Vec::new(),
2601            angular_outputs: Vec::new(),
2602            has_unharvestable_emits: false,
2603            has_dynamic_emit: false,
2604            has_emit_whole_object_use: false,
2605            load_return_keys: Vec::new(),
2606            has_unharvestable_load: false,
2607            has_load_data_whole_use: false,
2608            has_page_data_store_whole_use: false,
2609            has_route_loader_data_whole_use: false,
2610            component_functions: Vec::new(),
2611            react_props: Vec::new(),
2612            hook_uses: Vec::new(),
2613            render_edges: Vec::new(),
2614            svelte_dispatched_events: Vec::new(),
2615            svelte_listened_events: Vec::new(),
2616            angular_component_selectors: Vec::new(),
2617            registered_custom_elements: Vec::new(),
2618            used_custom_element_tags: Vec::new(),
2619            angular_used_selectors: Vec::new(),
2620            angular_entry_component_refs: Vec::new(),
2621            has_dynamic_component_render: false,
2622            has_dynamic_dispatch: false,
2623        }
2624    }
2625
2626    fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
2627        FileHealthScore {
2628            path: std::path::PathBuf::from(path),
2629            fan_in: 0,
2630            fan_out: 0,
2631            dead_code_ratio: 0.0,
2632            complexity_density: 0.0,
2633            maintainability_index,
2634            total_cyclomatic: 0,
2635            total_cognitive: 0,
2636            function_count: 1,
2637            lines: 1,
2638            crap_max,
2639            crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
2640        }
2641    }
2642
2643    #[test]
2644    fn file_score_crap_concern_tracks_crap_risk_bands() {
2645        assert!((file_score_crap_concern(0.0) - 0.0).abs() < f64::EPSILON);
2646        assert!((file_score_crap_concern(15.0) - 45.0).abs() < f64::EPSILON);
2647        assert!((file_score_crap_concern(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
2648        assert!((file_score_crap_concern(100.0) - 100.0).abs() < f64::EPSILON);
2649        assert!((file_score_crap_concern(552.0) - 100.0).abs() < f64::EPSILON);
2650    }
2651
2652    #[test]
2653    fn file_score_concern_axis_labels_dominant_signal() {
2654        let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
2655        assert_eq!(
2656            file_score_concern_axis(&risk_driven),
2657            FileScoreConcern::Risk
2658        );
2659        assert_eq!(file_score_concern_axis(&risk_driven).label(), "risk");
2660
2661        let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
2662        assert_eq!(
2663            file_score_concern_axis(&structure_driven),
2664            FileScoreConcern::Structural
2665        );
2666        assert_eq!(
2667            file_score_concern_axis(&structure_driven).label(),
2668            "structure"
2669        );
2670
2671        let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
2672        assert_eq!(
2673            file_score_concern_axis(&no_risk),
2674            FileScoreConcern::Structural
2675        );
2676    }
2677
2678    #[test]
2679    fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
2680        let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
2681        let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
2682
2683        let mut scores = [low_mi_low_risk, higher_mi_high_risk];
2684        scores.sort_by(compare_file_score_triage);
2685
2686        assert_eq!(
2687            scores[0].path,
2688            std::path::Path::new("/src/higher-mi-high-risk.ts")
2689        );
2690        assert_eq!(
2691            scores[1].path,
2692            std::path::Path::new("/src/low-mi-low-risk.ts")
2693        );
2694    }
2695
2696    #[test]
2697    fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
2698        let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
2699        let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
2700
2701        let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
2702        scores.sort_by(compare_file_score_triage);
2703
2704        assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
2705        assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
2706    }
2707
2708    #[test]
2709    fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
2710        let mut scores = [
2711            make_file_score("/src/b.ts", 70.0, 1.0),
2712            make_file_score("/src/a.ts", 70.0, 1.0),
2713            make_file_score("/src/higher-crap.ts", 70.0, 2.0),
2714            make_file_score("/src/lower-concern.ts", 80.0, 1.0),
2715        ];
2716
2717        scores.sort_by(compare_file_score_triage);
2718
2719        let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
2720        assert_eq!(
2721            paths,
2722            vec![
2723                std::path::Path::new("/src/higher-crap.ts"),
2724                std::path::Path::new("/src/a.ts"),
2725                std::path::Path::new("/src/b.ts"),
2726                std::path::Path::new("/src/lower-concern.ts"),
2727            ]
2728        );
2729    }
2730
2731    #[test]
2732    fn compute_file_scores_empty_graph() {
2733        let files: Vec<crate::discover::DiscoveredFile> = vec![];
2734        let graph = build_test_graph(&files, &[], &[]);
2735        let modules: Vec<crate::source::ModuleInfo> = vec![];
2736        let file_paths = rustc_hash::FxHashMap::default();
2737
2738        let output = crate::results::DeadCodeAnalysisArtifacts {
2739            results: fallow_types::results::AnalysisResults::default(),
2740            timings: None,
2741            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2742            modules: None,
2743            files: None,
2744            script_used_packages: rustc_hash::FxHashSet::default(),
2745            file_hashes: rustc_hash::FxHashMap::default(),
2746        };
2747
2748        let result = compute_file_scores(
2749            &modules,
2750            &file_paths,
2751            None,
2752            output,
2753            None,
2754            std::path::Path::new("/project"),
2755        )
2756        .unwrap();
2757        assert!(result.scores.is_empty());
2758        assert!(result.circular_files.is_empty());
2759        assert!(result.top_complex_fns.is_empty());
2760        assert!(result.entry_points.is_empty());
2761        assert_eq!(result.analysis_counts.total_exports, 0);
2762        assert_eq!(result.analysis_counts.dead_files, 0);
2763    }
2764
2765    #[test]
2766    fn compute_file_scores_no_graph_returns_error() {
2767        let modules: Vec<crate::source::ModuleInfo> = vec![];
2768        let file_paths = rustc_hash::FxHashMap::default();
2769
2770        let output = crate::results::DeadCodeAnalysisArtifacts {
2771            results: fallow_types::results::AnalysisResults::default(),
2772            timings: None,
2773            graph: None,
2774            modules: None,
2775            files: None,
2776            script_used_packages: rustc_hash::FxHashSet::default(),
2777            file_hashes: rustc_hash::FxHashMap::default(),
2778        };
2779
2780        let result = compute_file_scores(
2781            &modules,
2782            &file_paths,
2783            None,
2784            output,
2785            None,
2786            std::path::Path::new("/project"),
2787        );
2788        assert!(result.is_err());
2789        match result {
2790            Err(msg) => assert_eq!(msg, "graph not available"),
2791            Ok(_) => panic!("expected error"),
2792        }
2793    }
2794
2795    #[test]
2796    fn compute_file_scores_single_file_with_function() {
2797        let path_a = std::path::PathBuf::from("/src/a.ts");
2798        let files = vec![crate::discover::DiscoveredFile {
2799            id: crate::discover::FileId(0),
2800            path: path_a.clone(),
2801            size_bytes: 100,
2802        }];
2803
2804        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2805            file_id: crate::discover::FileId(0),
2806            path: path_a.clone(),
2807            exports: vec![fallow_types::extract::ExportInfo {
2808                name: crate::source::ExportName::Named("foo".into()),
2809                local_name: None,
2810                is_type_only: false,
2811                visibility: crate::source::VisibilityTag::None,
2812                expected_unused_reason: None,
2813                span: oxc_span::Span::empty(0),
2814                members: vec![],
2815                is_side_effect_used: false,
2816                super_class: None,
2817            }],
2818            ..Default::default()
2819        }];
2820
2821        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2822
2823        let modules = vec![make_module_info(
2824            0,
2825            10,
2826            vec![fallow_types::extract::FunctionComplexity {
2827                name: "foo".into(),
2828                line: 1,
2829                col: 0,
2830                cyclomatic: 5,
2831                cognitive: 3,
2832                line_count: 10,
2833                param_count: 0,
2834                react_hook_count: 0,
2835                react_jsx_max_depth: 0,
2836                react_prop_count: 0,
2837                source_hash: None,
2838                contributions: Vec::new(),
2839            }],
2840        )];
2841
2842        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2843            rustc_hash::FxHashMap::default();
2844        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2845
2846        let output = crate::results::DeadCodeAnalysisArtifacts {
2847            results: fallow_types::results::AnalysisResults::default(),
2848            timings: None,
2849            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2850            modules: None,
2851            files: None,
2852            script_used_packages: rustc_hash::FxHashSet::default(),
2853            file_hashes: rustc_hash::FxHashMap::default(),
2854        };
2855
2856        let result = compute_file_scores(
2857            &modules,
2858            &file_paths,
2859            None,
2860            output,
2861            None,
2862            std::path::Path::new("/project"),
2863        )
2864        .unwrap();
2865        assert_eq!(result.scores.len(), 1);
2866
2867        let score = &result.scores[0];
2868        assert_eq!(score.path, path_a);
2869        assert_eq!(score.total_cyclomatic, 5);
2870        assert_eq!(score.total_cognitive, 3);
2871        assert_eq!(score.function_count, 1);
2872        assert_eq!(score.lines, 10);
2873        assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
2874        assert!(score.dead_code_ratio.abs() < f64::EPSILON);
2875        assert!(result.entry_points.contains(&path_a));
2876    }
2877
2878    #[test]
2879    fn compute_file_scores_excludes_barrel_files() {
2880        let path_a = std::path::PathBuf::from("/src/index.ts");
2881        let files = vec![crate::discover::DiscoveredFile {
2882            id: crate::discover::FileId(0),
2883            path: path_a.clone(),
2884            size_bytes: 50,
2885        }];
2886
2887        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
2888            file_id: crate::discover::FileId(0),
2889            path: path_a.clone(),
2890            ..Default::default()
2891        }];
2892
2893        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
2894
2895        let modules = vec![make_module_info(0, 5, vec![])];
2896
2897        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2898            rustc_hash::FxHashMap::default();
2899        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2900
2901        let output = crate::results::DeadCodeAnalysisArtifacts {
2902            results: fallow_types::results::AnalysisResults::default(),
2903            timings: None,
2904            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
2905            modules: None,
2906            files: None,
2907            script_used_packages: rustc_hash::FxHashSet::default(),
2908            file_hashes: rustc_hash::FxHashMap::default(),
2909        };
2910
2911        let result = compute_file_scores(
2912            &modules,
2913            &file_paths,
2914            None,
2915            output,
2916            None,
2917            std::path::Path::new("/project"),
2918        )
2919        .unwrap();
2920        assert!(result.scores.is_empty());
2921    }
2922
2923    #[test]
2924    fn compute_file_scores_changed_since_filter() {
2925        let path_a = std::path::PathBuf::from("/src/a.ts");
2926        let path_b = std::path::PathBuf::from("/src/b.ts");
2927        let files = vec![
2928            crate::discover::DiscoveredFile {
2929                id: crate::discover::FileId(0),
2930                path: path_a.clone(),
2931                size_bytes: 100,
2932            },
2933            crate::discover::DiscoveredFile {
2934                id: crate::discover::FileId(1),
2935                path: path_b.clone(),
2936                size_bytes: 100,
2937            },
2938        ];
2939
2940        let resolved_modules = vec![
2941            fallow_graph::resolve::ResolvedModule {
2942                file_id: crate::discover::FileId(0),
2943                path: path_a,
2944                ..Default::default()
2945            },
2946            fallow_graph::resolve::ResolvedModule {
2947                file_id: crate::discover::FileId(1),
2948                path: path_b.clone(),
2949                ..Default::default()
2950            },
2951        ];
2952
2953        let graph = build_test_graph(&files, &[], &resolved_modules);
2954
2955        let modules = vec![
2956            make_module_info(
2957                0,
2958                10,
2959                vec![fallow_types::extract::FunctionComplexity {
2960                    name: "fn_a".into(),
2961                    line: 1,
2962                    col: 0,
2963                    cyclomatic: 2,
2964                    cognitive: 1,
2965                    line_count: 10,
2966                    param_count: 0,
2967                    react_hook_count: 0,
2968                    react_jsx_max_depth: 0,
2969                    react_prop_count: 0,
2970                    source_hash: None,
2971                    contributions: Vec::new(),
2972                }],
2973            ),
2974            make_module_info(
2975                1,
2976                10,
2977                vec![fallow_types::extract::FunctionComplexity {
2978                    name: "fn_b".into(),
2979                    line: 1,
2980                    col: 0,
2981                    cyclomatic: 3,
2982                    cognitive: 2,
2983                    line_count: 10,
2984                    param_count: 0,
2985                    react_hook_count: 0,
2986                    react_jsx_max_depth: 0,
2987                    react_prop_count: 0,
2988                    source_hash: None,
2989                    contributions: Vec::new(),
2990                }],
2991            ),
2992        ];
2993
2994        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
2995            rustc_hash::FxHashMap::default();
2996        file_paths.insert(crate::discover::FileId(0), &files[0].path);
2997        file_paths.insert(crate::discover::FileId(1), &files[1].path);
2998
2999        let path_b_check = std::path::PathBuf::from("/src/b.ts");
3000        let mut changed = rustc_hash::FxHashSet::default();
3001        changed.insert(path_b);
3002
3003        let output = crate::results::DeadCodeAnalysisArtifacts {
3004            results: fallow_types::results::AnalysisResults::default(),
3005            timings: None,
3006            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3007            modules: None,
3008            files: None,
3009            script_used_packages: rustc_hash::FxHashSet::default(),
3010            file_hashes: rustc_hash::FxHashMap::default(),
3011        };
3012
3013        let result = compute_file_scores(
3014            &modules,
3015            &file_paths,
3016            Some(&changed),
3017            output,
3018            None,
3019            std::path::Path::new("/project"),
3020        )
3021        .unwrap();
3022        assert_eq!(result.scores.len(), 1);
3023        assert_eq!(result.scores[0].path, path_b_check);
3024    }
3025
3026    #[test]
3027    fn compute_file_scores_sorted_by_triage_concern() {
3028        let path_a = std::path::PathBuf::from("/src/a.ts");
3029        let path_b = std::path::PathBuf::from("/src/b.ts");
3030        let files = vec![
3031            crate::discover::DiscoveredFile {
3032                id: crate::discover::FileId(0),
3033                path: path_a.clone(),
3034                size_bytes: 100,
3035            },
3036            crate::discover::DiscoveredFile {
3037                id: crate::discover::FileId(1),
3038                path: path_b.clone(),
3039                size_bytes: 100,
3040            },
3041        ];
3042
3043        let resolved_modules = vec![
3044            fallow_graph::resolve::ResolvedModule {
3045                file_id: crate::discover::FileId(0),
3046                path: path_a.clone(),
3047                ..Default::default()
3048            },
3049            fallow_graph::resolve::ResolvedModule {
3050                file_id: crate::discover::FileId(1),
3051                path: path_b,
3052                ..Default::default()
3053            },
3054        ];
3055
3056        let graph = build_test_graph(&files, &[], &resolved_modules);
3057
3058        let modules = vec![
3059            make_module_info(
3060                0,
3061                10,
3062                vec![fallow_types::extract::FunctionComplexity {
3063                    name: "complex_fn".into(),
3064                    line: 1,
3065                    col: 0,
3066                    cyclomatic: 30,
3067                    cognitive: 20,
3068                    line_count: 10,
3069                    param_count: 0,
3070                    react_hook_count: 0,
3071                    react_jsx_max_depth: 0,
3072                    react_prop_count: 0,
3073                    source_hash: None,
3074                    contributions: Vec::new(),
3075                }],
3076            ),
3077            make_module_info(
3078                1,
3079                100,
3080                vec![fallow_types::extract::FunctionComplexity {
3081                    name: "simple_fn".into(),
3082                    line: 1,
3083                    col: 0,
3084                    cyclomatic: 1,
3085                    cognitive: 0,
3086                    line_count: 100,
3087                    param_count: 0,
3088                    react_hook_count: 0,
3089                    react_jsx_max_depth: 0,
3090                    react_prop_count: 0,
3091                    source_hash: None,
3092                    contributions: Vec::new(),
3093                }],
3094            ),
3095        ];
3096
3097        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3098            rustc_hash::FxHashMap::default();
3099        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3100        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3101
3102        let output = crate::results::DeadCodeAnalysisArtifacts {
3103            results: fallow_types::results::AnalysisResults::default(),
3104            timings: None,
3105            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3106            modules: None,
3107            files: None,
3108            script_used_packages: rustc_hash::FxHashSet::default(),
3109            file_hashes: rustc_hash::FxHashMap::default(),
3110        };
3111
3112        let result = compute_file_scores(
3113            &modules,
3114            &file_paths,
3115            None,
3116            output,
3117            None,
3118            std::path::Path::new("/project"),
3119        )
3120        .unwrap();
3121        assert_eq!(result.scores.len(), 2);
3122        assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
3123        assert_eq!(result.scores[0].path, path_a);
3124    }
3125
3126    #[test]
3127    fn compute_file_scores_with_unused_file_populates_evidence() {
3128        let path_a = std::path::PathBuf::from("/src/unused.ts");
3129        let files = vec![crate::discover::DiscoveredFile {
3130            id: crate::discover::FileId(0),
3131            path: path_a.clone(),
3132            size_bytes: 100,
3133        }];
3134
3135        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3136            file_id: crate::discover::FileId(0),
3137            path: path_a.clone(),
3138            exports: vec![fallow_types::extract::ExportInfo {
3139                name: crate::source::ExportName::Named("orphan".into()),
3140                local_name: None,
3141                is_type_only: false,
3142                visibility: crate::source::VisibilityTag::None,
3143                expected_unused_reason: None,
3144                span: oxc_span::Span::empty(0),
3145                members: vec![],
3146                is_side_effect_used: false,
3147                super_class: None,
3148            }],
3149            ..Default::default()
3150        }];
3151
3152        let graph = build_test_graph(&files, &[], &resolved_modules);
3153
3154        let modules = vec![make_module_info(
3155            0,
3156            10,
3157            vec![fallow_types::extract::FunctionComplexity {
3158                name: "orphan".into(),
3159                line: 1,
3160                col: 0,
3161                cyclomatic: 1,
3162                cognitive: 0,
3163                line_count: 10,
3164                param_count: 0,
3165                react_hook_count: 0,
3166                react_jsx_max_depth: 0,
3167                react_prop_count: 0,
3168                source_hash: None,
3169                contributions: Vec::new(),
3170            }],
3171        )];
3172
3173        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3174            rustc_hash::FxHashMap::default();
3175        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3176
3177        let mut results = fallow_types::results::AnalysisResults::default();
3178        results.unused_files.push(
3179            fallow_types::output_dead_code::UnusedFileFinding::with_actions(
3180                fallow_types::results::UnusedFile {
3181                    path: path_a.clone(),
3182                },
3183            ),
3184        );
3185
3186        let output = crate::results::DeadCodeAnalysisArtifacts {
3187            results,
3188            timings: None,
3189            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3190            modules: None,
3191            files: None,
3192            script_used_packages: rustc_hash::FxHashSet::default(),
3193            file_hashes: rustc_hash::FxHashMap::default(),
3194        };
3195
3196        let result = compute_file_scores(
3197            &modules,
3198            &file_paths,
3199            None,
3200            output,
3201            None,
3202            std::path::Path::new("/project"),
3203        )
3204        .unwrap();
3205        assert_eq!(result.scores.len(), 1);
3206        assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
3207        assert!(result.unused_export_names.contains_key(&path_a));
3208        let names = &result.unused_export_names[&path_a];
3209        assert_eq!(names, &["orphan"]);
3210        assert_eq!(result.analysis_counts.dead_files, 1);
3211    }
3212
3213    #[test]
3214    #[expect(
3215        clippy::too_many_lines,
3216        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3217    )]
3218    fn compute_file_scores_tracks_top_complex_functions() {
3219        let path_a = std::path::PathBuf::from("/src/complex.ts");
3220        let files = vec![crate::discover::DiscoveredFile {
3221            id: crate::discover::FileId(0),
3222            path: path_a.clone(),
3223            size_bytes: 500,
3224        }];
3225
3226        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3227            file_id: crate::discover::FileId(0),
3228            path: path_a.clone(),
3229            ..Default::default()
3230        }];
3231
3232        let graph = build_test_graph(&files, &[], &resolved_modules);
3233
3234        let modules = vec![make_module_info(
3235            0,
3236            50,
3237            vec![
3238                fallow_types::extract::FunctionComplexity {
3239                    name: "high".into(),
3240                    line: 1,
3241                    col: 0,
3242                    cyclomatic: 10,
3243                    cognitive: 20,
3244                    line_count: 10,
3245                    param_count: 0,
3246                    react_hook_count: 0,
3247                    react_jsx_max_depth: 0,
3248                    react_prop_count: 0,
3249                    source_hash: None,
3250                    contributions: Vec::new(),
3251                },
3252                fallow_types::extract::FunctionComplexity {
3253                    name: "medium".into(),
3254                    line: 11,
3255                    col: 0,
3256                    cyclomatic: 5,
3257                    cognitive: 10,
3258                    line_count: 10,
3259                    param_count: 0,
3260                    react_hook_count: 0,
3261                    react_jsx_max_depth: 0,
3262                    react_prop_count: 0,
3263                    source_hash: None,
3264                    contributions: Vec::new(),
3265                },
3266                fallow_types::extract::FunctionComplexity {
3267                    name: "low".into(),
3268                    line: 21,
3269                    col: 0,
3270                    cyclomatic: 2,
3271                    cognitive: 5,
3272                    line_count: 10,
3273                    param_count: 0,
3274                    react_hook_count: 0,
3275                    react_jsx_max_depth: 0,
3276                    react_prop_count: 0,
3277                    source_hash: None,
3278                    contributions: Vec::new(),
3279                },
3280                fallow_types::extract::FunctionComplexity {
3281                    name: "trivial".into(),
3282                    line: 31,
3283                    col: 0,
3284                    cyclomatic: 1,
3285                    cognitive: 1,
3286                    line_count: 10,
3287                    param_count: 0,
3288                    react_hook_count: 0,
3289                    react_jsx_max_depth: 0,
3290                    react_prop_count: 0,
3291                    source_hash: None,
3292                    contributions: Vec::new(),
3293                },
3294            ],
3295        )];
3296
3297        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3298            rustc_hash::FxHashMap::default();
3299        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3300
3301        let output = crate::results::DeadCodeAnalysisArtifacts {
3302            results: fallow_types::results::AnalysisResults::default(),
3303            timings: None,
3304            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3305            modules: None,
3306            files: None,
3307            script_used_packages: rustc_hash::FxHashSet::default(),
3308            file_hashes: rustc_hash::FxHashMap::default(),
3309        };
3310
3311        let result = compute_file_scores(
3312            &modules,
3313            &file_paths,
3314            None,
3315            output,
3316            None,
3317            std::path::Path::new("/project"),
3318        )
3319        .unwrap();
3320        assert!(result.top_complex_fns.contains_key(&path_a));
3321        let top = &result.top_complex_fns[&path_a];
3322        assert_eq!(top.len(), 3);
3323        assert_eq!(top[0].0, "high");
3324        assert_eq!(top[0].2, 20);
3325        assert_eq!(top[1].0, "medium");
3326        assert_eq!(top[1].2, 10);
3327        assert_eq!(top[2].0, "low");
3328        assert_eq!(top[2].2, 5);
3329    }
3330
3331    #[test]
3332    #[expect(
3333        clippy::too_many_lines,
3334        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3335    )]
3336    fn compute_file_scores_with_circular_deps() {
3337        let path_a = std::path::PathBuf::from("/src/a.ts");
3338        let path_b = std::path::PathBuf::from("/src/b.ts");
3339        let files = vec![
3340            crate::discover::DiscoveredFile {
3341                id: crate::discover::FileId(0),
3342                path: path_a.clone(),
3343                size_bytes: 100,
3344            },
3345            crate::discover::DiscoveredFile {
3346                id: crate::discover::FileId(1),
3347                path: path_b.clone(),
3348                size_bytes: 100,
3349            },
3350        ];
3351
3352        let resolved_modules = vec![
3353            fallow_graph::resolve::ResolvedModule {
3354                file_id: crate::discover::FileId(0),
3355                path: path_a.clone(),
3356                ..Default::default()
3357            },
3358            fallow_graph::resolve::ResolvedModule {
3359                file_id: crate::discover::FileId(1),
3360                path: path_b.clone(),
3361                ..Default::default()
3362            },
3363        ];
3364
3365        let graph = build_test_graph(&files, &[], &resolved_modules);
3366
3367        let modules = vec![
3368            make_module_info(
3369                0,
3370                10,
3371                vec![fallow_types::extract::FunctionComplexity {
3372                    name: "fn_a".into(),
3373                    line: 1,
3374                    col: 0,
3375                    cyclomatic: 2,
3376                    cognitive: 1,
3377                    line_count: 10,
3378                    param_count: 0,
3379                    react_hook_count: 0,
3380                    react_jsx_max_depth: 0,
3381                    react_prop_count: 0,
3382                    source_hash: None,
3383                    contributions: Vec::new(),
3384                }],
3385            ),
3386            make_module_info(
3387                1,
3388                10,
3389                vec![fallow_types::extract::FunctionComplexity {
3390                    name: "fn_b".into(),
3391                    line: 1,
3392                    col: 0,
3393                    cyclomatic: 3,
3394                    cognitive: 2,
3395                    line_count: 10,
3396                    param_count: 0,
3397                    react_hook_count: 0,
3398                    react_jsx_max_depth: 0,
3399                    react_prop_count: 0,
3400                    source_hash: None,
3401                    contributions: Vec::new(),
3402                }],
3403            ),
3404        ];
3405
3406        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3407            rustc_hash::FxHashMap::default();
3408        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3409        file_paths.insert(crate::discover::FileId(1), &files[1].path);
3410
3411        let mut results = fallow_types::results::AnalysisResults::default();
3412        results.circular_dependencies.push(
3413            fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
3414                fallow_types::results::CircularDependency {
3415                    files: vec![path_a.clone(), path_b.clone()],
3416                    length: 2,
3417                    line: 1,
3418                    col: 0,
3419                    edges: Vec::new(),
3420                    is_cross_package: false,
3421                },
3422            ),
3423        );
3424
3425        let output = crate::results::DeadCodeAnalysisArtifacts {
3426            results,
3427            timings: None,
3428            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3429            modules: None,
3430            files: None,
3431            script_used_packages: rustc_hash::FxHashSet::default(),
3432            file_hashes: rustc_hash::FxHashMap::default(),
3433        };
3434
3435        let result = compute_file_scores(
3436            &modules,
3437            &file_paths,
3438            None,
3439            output,
3440            None,
3441            std::path::Path::new("/project"),
3442        )
3443        .unwrap();
3444        assert!(result.circular_files.contains(&path_a));
3445        assert!(result.circular_files.contains(&path_b));
3446        assert!(result.cycle_members.contains_key(&path_a));
3447        assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
3448        assert!(result.cycle_members.contains_key(&path_b));
3449        assert_eq!(result.cycle_members[&path_b], vec![path_a]);
3450        assert_eq!(result.analysis_counts.circular_deps, 1);
3451    }
3452
3453    #[test]
3454    #[expect(
3455        clippy::too_many_lines,
3456        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3457    )]
3458    fn compute_file_scores_analysis_counts_unused_exports_and_types() {
3459        let path_a = std::path::PathBuf::from("/src/a.ts");
3460        let files = vec![crate::discover::DiscoveredFile {
3461            id: crate::discover::FileId(0),
3462            path: path_a.clone(),
3463            size_bytes: 100,
3464        }];
3465
3466        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3467            file_id: crate::discover::FileId(0),
3468            path: path_a.clone(),
3469            exports: vec![
3470                fallow_types::extract::ExportInfo {
3471                    name: crate::source::ExportName::Named("foo".into()),
3472                    local_name: None,
3473                    is_type_only: false,
3474                    visibility: crate::source::VisibilityTag::None,
3475                    expected_unused_reason: None,
3476                    span: oxc_span::Span::empty(0),
3477                    members: vec![],
3478                    is_side_effect_used: false,
3479                    super_class: None,
3480                },
3481                fallow_types::extract::ExportInfo {
3482                    name: crate::source::ExportName::Named("bar".into()),
3483                    local_name: None,
3484                    is_type_only: false,
3485                    visibility: crate::source::VisibilityTag::None,
3486                    expected_unused_reason: None,
3487                    span: oxc_span::Span::empty(0),
3488                    members: vec![],
3489                    is_side_effect_used: false,
3490                    super_class: None,
3491                },
3492            ],
3493            ..Default::default()
3494        }];
3495
3496        let graph = build_test_graph(&files, &[], &resolved_modules);
3497
3498        let mut module = make_module_info(
3499            0,
3500            10,
3501            vec![fallow_types::extract::FunctionComplexity {
3502                name: "fn_a".into(),
3503                line: 1,
3504                col: 0,
3505                cyclomatic: 1,
3506                cognitive: 0,
3507                line_count: 10,
3508                param_count: 0,
3509                react_hook_count: 0,
3510                react_jsx_max_depth: 0,
3511                react_prop_count: 0,
3512                source_hash: None,
3513                contributions: Vec::new(),
3514            }],
3515        );
3516        module.exports = vec![
3517            fallow_types::extract::ExportInfo {
3518                name: crate::source::ExportName::Named("foo".into()),
3519                local_name: None,
3520                is_type_only: false,
3521                visibility: crate::source::VisibilityTag::None,
3522                expected_unused_reason: None,
3523                span: oxc_span::Span::empty(0),
3524                members: vec![],
3525                is_side_effect_used: false,
3526                super_class: None,
3527            },
3528            fallow_types::extract::ExportInfo {
3529                name: crate::source::ExportName::Named("bar".into()),
3530                local_name: None,
3531                is_type_only: false,
3532                visibility: crate::source::VisibilityTag::None,
3533                expected_unused_reason: None,
3534                span: oxc_span::Span::empty(0),
3535                members: vec![],
3536                is_side_effect_used: false,
3537                super_class: None,
3538            },
3539        ];
3540        let modules = vec![module];
3541
3542        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3543            rustc_hash::FxHashMap::default();
3544        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3545
3546        let mut results = fallow_types::results::AnalysisResults::default();
3547        results.unused_exports.push(
3548            fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3549                fallow_types::results::UnusedExport {
3550                    path: path_a.clone(),
3551                    export_name: "foo".into(),
3552                    is_type_only: false,
3553                    line: 1,
3554                    col: 0,
3555                    span_start: 0,
3556                    is_re_export: false,
3557                },
3558            ),
3559        );
3560        results.unused_types.push(
3561            fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
3562                fallow_types::results::UnusedExport {
3563                    path: path_a,
3564                    export_name: "MyType".into(),
3565                    is_type_only: true,
3566                    line: 5,
3567                    col: 0,
3568                    span_start: 40,
3569                    is_re_export: false,
3570                },
3571            ),
3572        );
3573        results.unused_dependencies.push(
3574            fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
3575                fallow_types::results::UnusedDependency {
3576                    package_name: "lodash".into(),
3577                    location: fallow_types::results::DependencyLocation::Dependencies,
3578                    path: std::path::PathBuf::from("/package.json"),
3579                    line: 1,
3580                    used_in_workspaces: Vec::new(),
3581                },
3582            ),
3583        );
3584
3585        let output = crate::results::DeadCodeAnalysisArtifacts {
3586            results,
3587            timings: None,
3588            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3589            modules: None,
3590            files: None,
3591            script_used_packages: rustc_hash::FxHashSet::default(),
3592            file_hashes: rustc_hash::FxHashMap::default(),
3593        };
3594
3595        let result = compute_file_scores(
3596            &modules,
3597            &file_paths,
3598            None,
3599            output,
3600            None,
3601            std::path::Path::new("/project"),
3602        )
3603        .unwrap();
3604        assert_eq!(result.analysis_counts.total_exports, 2);
3605        assert_eq!(result.analysis_counts.dead_exports, 2);
3606        assert_eq!(result.analysis_counts.unused_deps, 1);
3607    }
3608
3609    /// Regression: total_exports must count graph modules, not extraction modules.
3610    #[test]
3611    #[expect(
3612        clippy::too_many_lines,
3613        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3614    )]
3615    fn total_exports_counts_graph_modules_not_extraction_modules() {
3616        let path_a = std::path::PathBuf::from("/src/a.ts");
3617        let files = vec![crate::discover::DiscoveredFile {
3618            id: crate::discover::FileId(0),
3619            path: path_a.clone(),
3620            size_bytes: 100,
3621        }];
3622
3623        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3624            file_id: crate::discover::FileId(0),
3625            path: path_a.clone(),
3626            exports: vec![
3627                fallow_types::extract::ExportInfo {
3628                    name: crate::source::ExportName::Named("foo".into()),
3629                    local_name: None,
3630                    is_type_only: false,
3631                    visibility: crate::source::VisibilityTag::None,
3632                    expected_unused_reason: None,
3633                    span: oxc_span::Span::empty(0),
3634                    members: vec![],
3635                    is_side_effect_used: false,
3636                    super_class: None,
3637                },
3638                fallow_types::extract::ExportInfo {
3639                    name: crate::source::ExportName::Named("bar".into()),
3640                    local_name: None,
3641                    is_type_only: false,
3642                    visibility: crate::source::VisibilityTag::None,
3643                    expected_unused_reason: None,
3644                    span: oxc_span::Span::empty(0),
3645                    members: vec![],
3646                    is_side_effect_used: false,
3647                    super_class: None,
3648                },
3649                fallow_types::extract::ExportInfo {
3650                    name: crate::source::ExportName::Named("baz".into()),
3651                    local_name: None,
3652                    is_type_only: false,
3653                    visibility: crate::source::VisibilityTag::None,
3654                    expected_unused_reason: None,
3655                    span: oxc_span::Span::new(0, 0),
3656                    members: vec![],
3657                    is_side_effect_used: false,
3658                    super_class: None,
3659                },
3660            ],
3661            ..Default::default()
3662        }];
3663
3664        let graph = build_test_graph(&files, &[], &resolved_modules);
3665
3666        let mut module = make_module_info(
3667            0,
3668            10,
3669            vec![fallow_types::extract::FunctionComplexity {
3670                name: "fn_a".into(),
3671                line: 1,
3672                col: 0,
3673                cyclomatic: 1,
3674                cognitive: 0,
3675                line_count: 10,
3676                param_count: 0,
3677                react_hook_count: 0,
3678                react_jsx_max_depth: 0,
3679                react_prop_count: 0,
3680                source_hash: None,
3681                contributions: Vec::new(),
3682            }],
3683        );
3684        module.exports = vec![
3685            fallow_types::extract::ExportInfo {
3686                name: crate::source::ExportName::Named("foo".into()),
3687                local_name: None,
3688                is_type_only: false,
3689                visibility: crate::source::VisibilityTag::None,
3690                expected_unused_reason: None,
3691                span: oxc_span::Span::empty(0),
3692                members: vec![],
3693                is_side_effect_used: false,
3694                super_class: None,
3695            },
3696            fallow_types::extract::ExportInfo {
3697                name: crate::source::ExportName::Named("bar".into()),
3698                local_name: None,
3699                is_type_only: false,
3700                visibility: crate::source::VisibilityTag::None,
3701                expected_unused_reason: None,
3702                span: oxc_span::Span::empty(0),
3703                members: vec![],
3704                is_side_effect_used: false,
3705                super_class: None,
3706            },
3707        ];
3708        let modules = vec![module];
3709
3710        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3711            rustc_hash::FxHashMap::default();
3712        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3713
3714        let mut results = fallow_types::results::AnalysisResults::default();
3715        for name in ["foo", "bar", "baz"] {
3716            results.unused_exports.push(
3717                fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3718                    fallow_types::results::UnusedExport {
3719                        path: path_a.clone(),
3720                        export_name: name.into(),
3721                        is_type_only: false,
3722                        line: 1,
3723                        col: 0,
3724                        span_start: 0,
3725                        is_re_export: name == "baz",
3726                    },
3727                ),
3728            );
3729        }
3730
3731        let output = crate::results::DeadCodeAnalysisArtifacts {
3732            results,
3733            timings: None,
3734            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3735            modules: None,
3736            files: None,
3737            script_used_packages: rustc_hash::FxHashSet::default(),
3738            file_hashes: rustc_hash::FxHashMap::default(),
3739        };
3740
3741        let result = compute_file_scores(
3742            &modules,
3743            &file_paths,
3744            None,
3745            output,
3746            None,
3747            std::path::Path::new("/project"),
3748        )
3749        .unwrap();
3750        assert_eq!(result.analysis_counts.total_exports, 3);
3751        assert_eq!(result.analysis_counts.dead_exports, 3);
3752    }
3753
3754    #[test]
3755    fn compute_file_scores_module_not_in_file_paths_skipped() {
3756        let path_a = std::path::PathBuf::from("/src/a.ts");
3757        let files = vec![crate::discover::DiscoveredFile {
3758            id: crate::discover::FileId(0),
3759            path: path_a.clone(),
3760            size_bytes: 100,
3761        }];
3762
3763        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3764            file_id: crate::discover::FileId(0),
3765            path: path_a,
3766            ..Default::default()
3767        }];
3768
3769        let graph = build_test_graph(&files, &[], &resolved_modules);
3770
3771        let modules = vec![make_module_info(
3772            0,
3773            10,
3774            vec![fallow_types::extract::FunctionComplexity {
3775                name: "fn_a".into(),
3776                line: 1,
3777                col: 0,
3778                cyclomatic: 2,
3779                cognitive: 1,
3780                line_count: 10,
3781                param_count: 0,
3782                react_hook_count: 0,
3783                react_jsx_max_depth: 0,
3784                react_prop_count: 0,
3785                source_hash: None,
3786                contributions: Vec::new(),
3787            }],
3788        )];
3789
3790        let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3791            rustc_hash::FxHashMap::default();
3792
3793        let output = crate::results::DeadCodeAnalysisArtifacts {
3794            results: fallow_types::results::AnalysisResults::default(),
3795            timings: None,
3796            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3797            modules: None,
3798            files: None,
3799            script_used_packages: rustc_hash::FxHashSet::default(),
3800            file_hashes: rustc_hash::FxHashMap::default(),
3801        };
3802
3803        let result = compute_file_scores(
3804            &modules,
3805            &file_paths,
3806            None,
3807            output,
3808            None,
3809            std::path::Path::new("/project"),
3810        )
3811        .unwrap();
3812        assert!(result.scores.is_empty());
3813    }
3814
3815    #[test]
3816    fn compute_file_scores_mi_rounded_to_one_decimal() {
3817        let path_a = std::path::PathBuf::from("/src/a.ts");
3818        let files = vec![crate::discover::DiscoveredFile {
3819            id: crate::discover::FileId(0),
3820            path: path_a.clone(),
3821            size_bytes: 100,
3822        }];
3823
3824        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3825            file_id: crate::discover::FileId(0),
3826            path: path_a.clone(),
3827            ..Default::default()
3828        }];
3829
3830        let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
3831
3832        let modules = vec![make_module_info(
3833            0,
3834            100,
3835            vec![fallow_types::extract::FunctionComplexity {
3836                name: "fn".into(),
3837                line: 1,
3838                col: 0,
3839                cyclomatic: 7,
3840                cognitive: 3,
3841                line_count: 100,
3842                param_count: 0,
3843                react_hook_count: 0,
3844                react_jsx_max_depth: 0,
3845                react_prop_count: 0,
3846                source_hash: None,
3847                contributions: Vec::new(),
3848            }],
3849        )];
3850
3851        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3852            rustc_hash::FxHashMap::default();
3853        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3854
3855        let output = crate::results::DeadCodeAnalysisArtifacts {
3856            results: fallow_types::results::AnalysisResults::default(),
3857            timings: None,
3858            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3859            modules: None,
3860            files: None,
3861            script_used_packages: rustc_hash::FxHashSet::default(),
3862            file_hashes: rustc_hash::FxHashMap::default(),
3863        };
3864
3865        let result = compute_file_scores(
3866            &modules,
3867            &file_paths,
3868            None,
3869            output,
3870            None,
3871            std::path::Path::new("/project"),
3872        )
3873        .unwrap();
3874        let mi = result.scores[0].maintainability_index;
3875        let rounded = (mi * 10.0).round() / 10.0;
3876        assert!((mi - rounded).abs() < f64::EPSILON);
3877    }
3878
3879    #[test]
3880    fn compute_file_scores_value_export_counts_tracked() {
3881        let path_a = std::path::PathBuf::from("/src/a.ts");
3882        let files = vec![crate::discover::DiscoveredFile {
3883            id: crate::discover::FileId(0),
3884            path: path_a.clone(),
3885            size_bytes: 100,
3886        }];
3887
3888        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3889            file_id: crate::discover::FileId(0),
3890            path: path_a.clone(),
3891            exports: vec![
3892                fallow_types::extract::ExportInfo {
3893                    name: crate::source::ExportName::Named("a".into()),
3894                    local_name: None,
3895                    is_type_only: false,
3896                    visibility: crate::source::VisibilityTag::None,
3897                    expected_unused_reason: None,
3898                    span: oxc_span::Span::empty(0),
3899                    members: vec![],
3900                    is_side_effect_used: false,
3901                    super_class: None,
3902                },
3903                fallow_types::extract::ExportInfo {
3904                    name: crate::source::ExportName::Named("b".into()),
3905                    local_name: None,
3906                    is_type_only: false,
3907                    visibility: crate::source::VisibilityTag::None,
3908                    expected_unused_reason: None,
3909                    span: oxc_span::Span::empty(0),
3910                    members: vec![],
3911                    is_side_effect_used: false,
3912                    super_class: None,
3913                },
3914                fallow_types::extract::ExportInfo {
3915                    name: crate::source::ExportName::Named("T".into()),
3916                    local_name: None,
3917                    is_type_only: true,
3918                    visibility: crate::source::VisibilityTag::None,
3919                    expected_unused_reason: None,
3920                    span: oxc_span::Span::empty(0),
3921                    members: vec![],
3922                    is_side_effect_used: false,
3923                    super_class: None,
3924                },
3925            ],
3926            ..Default::default()
3927        }];
3928
3929        let graph = build_test_graph(&files, &[], &resolved_modules);
3930
3931        let modules = vec![make_module_info(
3932            0,
3933            10,
3934            vec![fallow_types::extract::FunctionComplexity {
3935                name: "fn_a".into(),
3936                line: 1,
3937                col: 0,
3938                cyclomatic: 2,
3939                cognitive: 1,
3940                line_count: 10,
3941                param_count: 0,
3942                react_hook_count: 0,
3943                react_jsx_max_depth: 0,
3944                react_prop_count: 0,
3945                source_hash: None,
3946                contributions: Vec::new(),
3947            }],
3948        )];
3949
3950        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3951            rustc_hash::FxHashMap::default();
3952        file_paths.insert(crate::discover::FileId(0), &files[0].path);
3953
3954        let output = crate::results::DeadCodeAnalysisArtifacts {
3955            results: fallow_types::results::AnalysisResults::default(),
3956            timings: None,
3957            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3958            modules: None,
3959            files: None,
3960            script_used_packages: rustc_hash::FxHashSet::default(),
3961            file_hashes: rustc_hash::FxHashMap::default(),
3962        };
3963
3964        let result = compute_file_scores(
3965            &modules,
3966            &file_paths,
3967            None,
3968            output,
3969            None,
3970            std::path::Path::new("/project"),
3971        )
3972        .unwrap();
3973        assert_eq!(result.value_export_counts[&path_a], 2);
3974    }
3975
3976    #[test]
3977    fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
3978        let path_a = std::path::PathBuf::from("/src/simple.ts");
3979        let files = vec![crate::discover::DiscoveredFile {
3980            id: crate::discover::FileId(0),
3981            path: path_a.clone(),
3982            size_bytes: 100,
3983        }];
3984
3985        let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3986            file_id: crate::discover::FileId(0),
3987            path: path_a.clone(),
3988            ..Default::default()
3989        }];
3990
3991        let graph = build_test_graph(&files, &[], &resolved_modules);
3992
3993        let modules = vec![make_module_info(
3994            0,
3995            10,
3996            vec![fallow_types::extract::FunctionComplexity {
3997                name: "trivial".into(),
3998                line: 1,
3999                col: 0,
4000                cyclomatic: 1,
4001                cognitive: 0,
4002                line_count: 10,
4003                param_count: 0,
4004                react_hook_count: 0,
4005                react_jsx_max_depth: 0,
4006                react_prop_count: 0,
4007                source_hash: None,
4008                contributions: Vec::new(),
4009            }],
4010        )];
4011
4012        let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4013            rustc_hash::FxHashMap::default();
4014        file_paths.insert(crate::discover::FileId(0), &files[0].path);
4015
4016        let output = crate::results::DeadCodeAnalysisArtifacts {
4017            results: fallow_types::results::AnalysisResults::default(),
4018            timings: None,
4019            graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4020            modules: None,
4021            files: None,
4022            script_used_packages: rustc_hash::FxHashSet::default(),
4023            file_hashes: rustc_hash::FxHashMap::default(),
4024        };
4025
4026        let result = compute_file_scores(
4027            &modules,
4028            &file_paths,
4029            None,
4030            output,
4031            None,
4032            std::path::Path::new("/project"),
4033        )
4034        .unwrap();
4035        assert!(!result.top_complex_fns.contains_key(&path_a));
4036    }
4037
4038    fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
4039        fallow_types::extract::FunctionComplexity {
4040            name: "test_fn".into(),
4041            line: 1,
4042            col: 0,
4043            cyclomatic,
4044            cognitive: 0,
4045            line_count: 10,
4046            param_count: 0,
4047            react_hook_count: 0,
4048            react_jsx_max_depth: 0,
4049            react_prop_count: 0,
4050            source_hash: None,
4051            contributions: Vec::new(),
4052        }
4053    }
4054
4055    #[test]
4056    fn crap_scores_empty_complexity() {
4057        let (max, above) = compute_crap_scores_binary(&[], true);
4058        assert!((max).abs() < f64::EPSILON);
4059        assert_eq!(above, 0);
4060    }
4061
4062    #[test]
4063    fn crap_scores_test_reachable() {
4064        let funcs = vec![make_fn_complexity(5)];
4065        let (max, above) = compute_crap_scores_binary(&funcs, true);
4066        assert!((max - 5.0).abs() < f64::EPSILON);
4067        assert_eq!(above, 0);
4068    }
4069
4070    #[test]
4071    fn crap_scores_untested_at_threshold() {
4072        let funcs = vec![make_fn_complexity(5)];
4073        let (max, above) = compute_crap_scores_binary(&funcs, false);
4074        assert!((max - 30.0).abs() < f64::EPSILON);
4075        assert_eq!(above, 1);
4076    }
4077
4078    #[test]
4079    fn crap_scores_untested_above_threshold() {
4080        let funcs = vec![make_fn_complexity(6)];
4081        let (max, above) = compute_crap_scores_binary(&funcs, false);
4082        assert!((max - 42.0).abs() < f64::EPSILON);
4083        assert_eq!(above, 1);
4084    }
4085
4086    #[test]
4087    fn crap_scores_untested_below_threshold() {
4088        let funcs = vec![make_fn_complexity(4)];
4089        let (max, above) = compute_crap_scores_binary(&funcs, false);
4090        assert!((max - 20.0).abs() < f64::EPSILON);
4091        assert_eq!(above, 0);
4092    }
4093
4094    #[test]
4095    fn crap_scores_mixed_functions_untested() {
4096        let funcs = vec![
4097            make_fn_complexity(2),
4098            make_fn_complexity(5),
4099            make_fn_complexity(8),
4100        ];
4101        let (max, above) = compute_crap_scores_binary(&funcs, false);
4102        assert!((max - 72.0).abs() < f64::EPSILON);
4103        assert_eq!(above, 2);
4104    }
4105
4106    #[test]
4107    fn crap_formula_full_coverage() {
4108        let result = crap_formula(10.0, 100.0);
4109        assert!((result - 10.0).abs() < f64::EPSILON);
4110    }
4111
4112    #[test]
4113    fn crap_formula_zero_coverage() {
4114        let result = crap_formula(5.0, 0.0);
4115        assert!((result - 30.0).abs() < f64::EPSILON);
4116    }
4117
4118    #[test]
4119    fn crap_formula_partial_coverage() {
4120        let result = crap_formula(10.0, 50.0);
4121        assert!((result - 22.5).abs() < f64::EPSILON);
4122    }
4123
4124    #[test]
4125    fn crap_formula_high_coverage_low_complexity() {
4126        let result = crap_formula(2.0, 90.0);
4127        assert!((result - 2.004).abs() < 0.001);
4128    }
4129
4130    #[test]
4131    fn istanbul_crap_with_coverage_data() {
4132        let funcs = vec![make_fn_complexity(10)];
4133        let mut functions = rustc_hash::FxHashMap::default();
4134        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4135        let file_cov = IstanbulFileCoverage { functions };
4136        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4137        assert!((result.max_crap - 10.8).abs() < 0.1);
4138        assert_eq!(result.above_threshold, 0);
4139    }
4140
4141    #[test]
4142    fn istanbul_crap_falls_back_to_binary_when_no_match() {
4143        let funcs = vec![make_fn_complexity(6)];
4144        let file_cov = IstanbulFileCoverage {
4145            functions: rustc_hash::FxHashMap::default(),
4146        };
4147        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4148        assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
4149        assert_eq!(result.above_threshold, 1);
4150    }
4151
4152    #[test]
4153    fn istanbul_crap_falls_back_to_binary_when_no_file_coverage() {
4154        let funcs = vec![make_fn_complexity(5)];
4155        let result = compute_crap_scores_istanbul(&funcs, None, true);
4156        assert!((result.max_crap - 5.0).abs() < f64::EPSILON);
4157        assert_eq!(result.above_threshold, 0);
4158    }
4159
4160    #[test]
4161    fn istanbul_crap_zero_coverage_matches_binary_untested() {
4162        let funcs = vec![make_fn_complexity(5)];
4163        let mut functions = rustc_hash::FxHashMap::default();
4164        functions.insert(("test_fn".to_string(), 1, 0), 0.0);
4165        let file_cov = IstanbulFileCoverage { functions };
4166        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), false);
4167        assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
4168        assert_eq!(result.above_threshold, 1);
4169    }
4170
4171    #[test]
4172    fn estimated_crap_direct_test_reference() {
4173        let funcs = vec![make_fn_complexity(10)];
4174        let mut refs = rustc_hash::FxHashSet::default();
4175        refs.insert("test_fn".to_string());
4176        let result = compute_crap_scores_estimated(
4177            &funcs,
4178            &refs,
4179            true,
4180            fallow_output::CoverageSource::Estimated,
4181        );
4182        let (max, above) = (result.max_crap, result.above_threshold);
4183        assert!((max - 10.3).abs() < 0.1);
4184        assert_eq!(above, 0);
4185    }
4186
4187    #[test]
4188    fn estimated_crap_indirect_test_reachable() {
4189        let funcs = vec![make_fn_complexity(10)];
4190        let refs = rustc_hash::FxHashSet::default();
4191        let result = compute_crap_scores_estimated(
4192            &funcs,
4193            &refs,
4194            true,
4195            fallow_output::CoverageSource::Estimated,
4196        );
4197        let (max, above) = (result.max_crap, result.above_threshold);
4198        assert!((max - 31.6).abs() < 0.1);
4199        assert_eq!(above, 1);
4200    }
4201
4202    #[test]
4203    fn estimated_crap_untested_file() {
4204        let funcs = vec![make_fn_complexity(5)];
4205        let refs = rustc_hash::FxHashSet::default();
4206        let result = compute_crap_scores_estimated(
4207            &funcs,
4208            &refs,
4209            false,
4210            fallow_output::CoverageSource::Estimated,
4211        );
4212        let (max, above) = (result.max_crap, result.above_threshold);
4213        assert!((max - 30.0).abs() < f64::EPSILON);
4214        assert_eq!(above, 1);
4215    }
4216
4217    #[test]
4218    fn estimated_crap_low_complexity_direct_ref() {
4219        let funcs = vec![make_fn_complexity(2)];
4220        let mut refs = rustc_hash::FxHashSet::default();
4221        refs.insert("test_fn".to_string());
4222        let result = compute_crap_scores_estimated(
4223            &funcs,
4224            &refs,
4225            true,
4226            fallow_output::CoverageSource::Estimated,
4227        );
4228        let (max, above) = (result.max_crap, result.above_threshold);
4229        assert!(max < 3.0);
4230        assert_eq!(above, 0);
4231    }
4232
4233    #[test]
4234    fn estimated_crap_empty() {
4235        let refs = rustc_hash::FxHashSet::default();
4236        let result = compute_crap_scores_estimated(
4237            &[],
4238            &refs,
4239            true,
4240            fallow_output::CoverageSource::Estimated,
4241        );
4242        let (max, above) = (result.max_crap, result.above_threshold);
4243        assert!((max).abs() < f64::EPSILON);
4244        assert_eq!(above, 0);
4245    }
4246
4247    fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
4248        fallow_graph::graph::ExportSymbol {
4249            name: fallow_types::extract::ExportName::Named(name.into()),
4250            is_type_only,
4251            is_side_effect_used: false,
4252            visibility: crate::source::VisibilityTag::None,
4253            expected_unused_reason: None,
4254            span: oxc_span::Span::default(),
4255            references: vec![],
4256            reference_paths: Vec::new(),
4257            members: vec![],
4258        }
4259    }
4260
4261    #[test]
4262    fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
4263        let path = std::path::Path::new("src/types.ts");
4264        let exports = vec![
4265            make_export("MyInterface", true),
4266            make_export("MyType", true),
4267            make_export("myFunction", false),
4268        ];
4269        let unused_files = rustc_hash::FxHashSet::default();
4270        let mut unused_by_path = rustc_hash::FxHashMap::default();
4271        unused_by_path.insert(path, 1_usize); // 1 unused value export
4272
4273        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4274        assert!((ratio - 1.0).abs() < f64::EPSILON);
4275    }
4276
4277    #[test]
4278    fn dead_code_ratio_only_type_exports_returns_zero() {
4279        let path = std::path::Path::new("src/types.ts");
4280        let exports = vec![
4281            make_export("MyInterface", true),
4282            make_export("MyType", true),
4283        ];
4284        let unused_files = rustc_hash::FxHashSet::default();
4285        let unused_by_path = rustc_hash::FxHashMap::default();
4286
4287        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4288        assert!(ratio.abs() < f64::EPSILON);
4289    }
4290
4291    #[test]
4292    fn dead_code_ratio_mixed_exports_counts_only_values() {
4293        let path = std::path::Path::new("src/component.ts");
4294        let exports = vec![
4295            make_export("Props", true),
4296            make_export("State", true),
4297            make_export("Component", false),
4298            make_export("helper", false),
4299        ];
4300        let unused_files = rustc_hash::FxHashSet::default();
4301        let mut unused_by_path = rustc_hash::FxHashMap::default();
4302        unused_by_path.insert(path, 1_usize);
4303
4304        let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
4305        assert!((ratio - 0.5).abs() < f64::EPSILON);
4306    }
4307
4308    fn write_single_file_istanbul_fixture(
4309        coverage_path: &std::path::Path,
4310        source_path: &std::path::Path,
4311        fn_map: &serde_json::Value,
4312        function_hits: &serde_json::Value,
4313    ) {
4314        let mut root = serde_json::Map::new();
4315        root.insert(
4316            source_path.to_string_lossy().into_owned(),
4317            serde_json::json!({
4318                "path": source_path.to_string_lossy().into_owned(),
4319                "statementMap": {},
4320                "fnMap": fn_map,
4321                "branchMap": {},
4322                "s": {},
4323                "f": function_hits,
4324                "b": {}
4325            }),
4326        );
4327
4328        std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
4329    }
4330
4331    #[test]
4332    fn resolve_relative_to_root_joins_relative_with_project_root() {
4333        let resolved = resolve_relative_to_root(
4334            std::path::Path::new("coverage/coverage-final.json"),
4335            Some(std::path::Path::new("/work/my-app")),
4336        );
4337        assert_eq!(
4338            resolved,
4339            std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
4340        );
4341    }
4342
4343    #[test]
4344    fn resolve_relative_to_root_returns_absolute_unchanged() {
4345        let resolved = resolve_relative_to_root(
4346            std::path::Path::new("/tmp/coverage-final.json"),
4347            Some(std::path::Path::new("/work/my-app")),
4348        );
4349        assert_eq!(
4350            resolved,
4351            std::path::PathBuf::from("/tmp/coverage-final.json")
4352        );
4353    }
4354
4355    #[test]
4356    fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
4357        let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
4358        let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
4359        assert_eq!(resolved, path);
4360    }
4361
4362    #[cfg(windows)]
4363    #[test]
4364    fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
4365        let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
4366        let resolved =
4367            resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
4368        assert_eq!(resolved, path);
4369    }
4370
4371    #[test]
4372    fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
4373        let resolved =
4374            resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
4375        assert_eq!(
4376            resolved,
4377            std::path::PathBuf::from("coverage/coverage-final.json")
4378        );
4379    }
4380
4381    #[test]
4382    fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
4383        let temp = tempfile::TempDir::new().unwrap();
4384        let source_path = temp.path().join("src/index.ts");
4385        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4386        std::fs::write(&source_path, "export function f(){}").unwrap();
4387
4388        let coverage_path = temp.path().join("coverage/coverage-final.json");
4389        std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
4390        write_single_file_istanbul_fixture(
4391            &coverage_path,
4392            &source_path,
4393            &serde_json::json!({
4394                "0": {
4395                    "name": "f",
4396                    "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
4397                    "loc":  { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
4398                }
4399            }),
4400            &serde_json::json!({ "0": 1 }),
4401        );
4402
4403        let coverage = load_istanbul_coverage(
4404            std::path::Path::new("coverage/coverage-final.json"),
4405            None,
4406            Some(temp.path()),
4407        )
4408        .expect("relative path must resolve against project_root");
4409        assert!(
4410            !coverage.files.is_empty(),
4411            "expected coverage to load via project_root resolution, got {} files",
4412            coverage.files.len()
4413        );
4414    }
4415
4416    #[test]
4417    fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
4418        let temp = tempfile::TempDir::new().unwrap();
4419        let source_path = temp.path().join("src/service.ts");
4420        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4421        std::fs::write(&source_path, "export class DataService {}\n").unwrap();
4422
4423        let coverage_path = temp.path().join("coverage-final.json");
4424        write_single_file_istanbul_fixture(
4425            &coverage_path,
4426            &source_path,
4427            &serde_json::json!({
4428                "0": {
4429                    "name": "(anonymous_0)",
4430                    "decl": {
4431                        "start": { "line": 5, "column": 2 },
4432                        "end": { "line": 5, "column": 13 }
4433                    },
4434                    "loc": {
4435                        "start": { "line": 5, "column": 14 },
4436                        "end": { "line": 11, "column": 3 }
4437                    }
4438                },
4439                "1": {
4440                    "name": "(anonymous_1)",
4441                    "decl": {
4442                        "start": { "line": 20, "column": 14 },
4443                        "end": { "line": 20, "column": 25 }
4444                    },
4445                    "loc": {
4446                        "start": { "line": 20, "column": 28 },
4447                        "end": { "line": 22, "column": 2 }
4448                    }
4449                }
4450            }),
4451            &serde_json::json!({
4452                "0": 1,
4453                "1": 0
4454            }),
4455        );
4456
4457        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4458        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4459        let file_coverage = coverage.get(&canonical_source).unwrap();
4460
4461        assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
4462        assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
4463    }
4464
4465    #[test]
4466    fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
4467        let temp = tempfile::TempDir::new().unwrap();
4468        let source_path = temp.path().join("src/handler.ts");
4469        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4470        std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
4471
4472        let coverage_path = temp.path().join("coverage-final.json");
4473        write_single_file_istanbul_fixture(
4474            &coverage_path,
4475            &source_path,
4476            &serde_json::json!({
4477                "0": {
4478                    "name": "handleClick",
4479                    "line": 40,
4480                    "decl": {
4481                        "start": { "line": 22, "column": 13 },
4482                        "end": { "line": 22, "column": 24 }
4483                    },
4484                    "loc": {
4485                        "start": { "line": 40, "column": 27 },
4486                        "end": { "line": 42, "column": 1 }
4487                    }
4488                }
4489            }),
4490            &serde_json::json!({
4491                "0": 1
4492            }),
4493        );
4494
4495        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4496        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4497        let file_coverage = coverage.get(&canonical_source).unwrap();
4498
4499        assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
4500        assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
4501    }
4502
4503    #[test]
4504    fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
4505        let temp = tempfile::TempDir::new().unwrap();
4506        let source_path = temp.path().join("src/actor.ts");
4507        std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
4508        std::fs::write(
4509            &source_path,
4510            "export const elementsFrom = async (\n  locator: AnyLocator,\n  options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n  return [];\n};\n",
4511        )
4512        .unwrap();
4513
4514        let coverage_path = temp.path().join("coverage-final.json");
4515        write_single_file_istanbul_fixture(
4516            &coverage_path,
4517            &source_path,
4518            &serde_json::json!({
4519                "0": {
4520                    "name": "(anonymous_0)",
4521                    "line": 4,
4522                    "decl": {
4523                        "start": { "line": 1, "column": 28 },
4524                        "end": { "line": 4, "column": 26 }
4525                    },
4526                    "loc": {
4527                        "start": { "line": 4, "column": 27 },
4528                        "end": { "line": 6, "column": 1 }
4529                    }
4530                }
4531            }),
4532            &serde_json::json!({
4533                "0": 642
4534            }),
4535        );
4536
4537        let coverage = load_istanbul_coverage(&coverage_path, None, None).unwrap();
4538        let canonical_source = dunce::canonicalize(&source_path).unwrap();
4539        let file_coverage = coverage.get(&canonical_source).unwrap();
4540
4541        assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
4542    }
4543
4544    #[test]
4545    fn istanbul_lookup_exact_match() {
4546        let mut functions = rustc_hash::FxHashMap::default();
4547        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4548        let fc = IstanbulFileCoverage { functions };
4549        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
4550    }
4551
4552    #[test]
4553    fn istanbul_lookup_fuzzy_match_within_offset() {
4554        let mut functions = rustc_hash::FxHashMap::default();
4555        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4556        let fc = IstanbulFileCoverage { functions };
4557        assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4558        assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
4559    }
4560
4561    #[test]
4562    fn istanbul_lookup_fuzzy_match_outside_offset() {
4563        let mut functions = rustc_hash::FxHashMap::default();
4564        functions.insert(("handleClick".to_string(), 10, 0), 72.0);
4565        let fc = IstanbulFileCoverage { functions };
4566        assert!(fc.lookup("handleClick", 13, 0).is_none());
4567    }
4568
4569    #[test]
4570    fn istanbul_lookup_name_mismatch() {
4571        let mut functions = rustc_hash::FxHashMap::default();
4572        functions.insert(("handleClick".to_string(), 10, 0), 85.0);
4573        let fc = IstanbulFileCoverage { functions };
4574        assert!(fc.lookup("handleSubmit", 10, 0).is_none());
4575    }
4576
4577    #[test]
4578    fn istanbul_lookup_empty() {
4579        let fc = IstanbulFileCoverage {
4580            functions: rustc_hash::FxHashMap::default(),
4581        };
4582        assert!(fc.lookup("anything", 1, 0).is_none());
4583    }
4584
4585    #[test]
4586    fn istanbul_lookup_fuzzy_picks_closest() {
4587        let mut functions = rustc_hash::FxHashMap::default();
4588        functions.insert(("render".to_string(), 8, 0), 60.0);
4589        functions.insert(("render".to_string(), 12, 0), 90.0);
4590        let fc = IstanbulFileCoverage { functions };
4591        let result = fc.lookup("render", 10, 0);
4592        assert!(result.is_some());
4593        let pct = result.unwrap();
4594        assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
4595    }
4596
4597    #[test]
4598    fn istanbul_lookup_anonymous_fallback_single_candidate() {
4599        let mut functions = rustc_hash::FxHashMap::default();
4600        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4601        let fc = IstanbulFileCoverage { functions };
4602        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4603        assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4604    }
4605
4606    #[test]
4607    fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
4608        let mut functions = rustc_hash::FxHashMap::default();
4609        functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
4610        let fc = IstanbulFileCoverage { functions };
4611
4612        assert!(fc.lookup("declaredHelper", 3, 0).is_none());
4613    }
4614
4615    #[test]
4616    fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
4617        let mut functions = rustc_hash::FxHashMap::default();
4618        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4619        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
4620        let fc = IstanbulFileCoverage { functions };
4621        assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
4622    }
4623
4624    #[test]
4625    fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
4626        let mut functions = rustc_hash::FxHashMap::default();
4627        functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); // outer
4628        functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); // inner
4629        let fc = IstanbulFileCoverage { functions };
4630        assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
4631        assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
4632    }
4633
4634    #[test]
4635    fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
4636        let mut functions = rustc_hash::FxHashMap::default();
4637        functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
4638        functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
4639        let fc = IstanbulFileCoverage { functions };
4640        assert!(fc.lookup("myHandler", 28, 0).is_none());
4641    }
4642
4643    #[test]
4644    fn istanbul_lookup_anonymous_fallback_outside_offset() {
4645        let mut functions = rustc_hash::FxHashMap::default();
4646        functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
4647        let fc = IstanbulFileCoverage { functions };
4648        assert!(fc.lookup("myHandler", 31, 0).is_none());
4649    }
4650
4651    #[test]
4652    fn istanbul_lookup_named_match_beats_nearby_anonymous() {
4653        let mut functions = rustc_hash::FxHashMap::default();
4654        functions.insert(("handleClick".to_string(), 10, 0), 90.0);
4655        functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
4656        let fc = IstanbulFileCoverage { functions };
4657        assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
4658    }
4659
4660    #[test]
4661    fn build_test_refs_empty() {
4662        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
4663        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
4664        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
4665        assert!(refs.is_empty());
4666    }
4667
4668    #[test]
4669    fn build_test_refs_empty_inputs() {
4670        let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
4671        let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
4672        let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
4673        assert!(refs.is_empty());
4674    }
4675
4676    #[test]
4677    fn istanbul_crap_empty_complexity() {
4678        let result = compute_crap_scores_istanbul(&[], None, false);
4679        assert!((result.max_crap).abs() < f64::EPSILON);
4680        assert_eq!(result.above_threshold, 0);
4681        assert_eq!(result.matched, 0);
4682        assert_eq!(result.total, 0);
4683    }
4684
4685    #[test]
4686    fn istanbul_crap_match_statistics() {
4687        let funcs = vec![make_fn_complexity(5), {
4688            let mut f = make_fn_complexity(3);
4689            f.name = "other_fn".into();
4690            f.line = 10;
4691            f
4692        }];
4693        let mut functions = rustc_hash::FxHashMap::default();
4694        functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4695        let file_cov = IstanbulFileCoverage { functions };
4696        let result = compute_crap_scores_istanbul(&funcs, Some(&file_cov), true);
4697        assert_eq!(result.matched, 1);
4698        assert_eq!(result.total, 2);
4699    }
4700
4701    #[test]
4702    fn estimated_crap_multiple_functions_mixed_coverage() {
4703        let funcs = vec![
4704            make_fn_complexity(10), // name "test_fn" line 1
4705            {
4706                let mut f = make_fn_complexity(3);
4707                f.name = "helper".into();
4708                f.line = 20;
4709                f
4710            },
4711        ];
4712        let mut refs = rustc_hash::FxHashSet::default();
4713        refs.insert("test_fn".to_string());
4714        let result = compute_crap_scores_estimated(
4715            &funcs,
4716            &refs,
4717            true,
4718            fallow_output::CoverageSource::Estimated,
4719        );
4720        let (max, above) = (result.max_crap, result.above_threshold);
4721        assert!(max > 10.0);
4722        assert_eq!(above, 0);
4723    }
4724
4725    #[test]
4726    fn binary_crap_test_reachable() {
4727        let funcs = vec![make_fn_complexity(10)];
4728        let (max, above) = compute_crap_scores_binary(&funcs, true);
4729        assert!((max - 10.0).abs() < f64::EPSILON);
4730        assert_eq!(above, 0);
4731    }
4732
4733    #[test]
4734    fn binary_crap_not_reachable() {
4735        let funcs = vec![make_fn_complexity(6)];
4736        let (max, above) = compute_crap_scores_binary(&funcs, false);
4737        assert!((max - 42.0).abs() < f64::EPSILON);
4738        assert_eq!(above, 1);
4739    }
4740
4741    #[test]
4742    fn binary_crap_threshold_boundary() {
4743        let funcs = vec![make_fn_complexity(5)];
4744        let (max, above) = compute_crap_scores_binary(&funcs, false);
4745        assert!((max - 30.0).abs() < f64::EPSILON);
4746        assert_eq!(above, 1);
4747    }
4748
4749    #[test]
4750    fn binary_crap_empty() {
4751        let (max, above) = compute_crap_scores_binary(&[], true);
4752        assert!((max).abs() < f64::EPSILON);
4753        assert_eq!(above, 0);
4754    }
4755
4756    #[test]
4757    fn binary_crap_multiple_functions() {
4758        let funcs = vec![make_fn_complexity(3), make_fn_complexity(8)];
4759        let (max, above) = compute_crap_scores_binary(&funcs, false);
4760        assert!((max - 72.0).abs() < f64::EPSILON);
4761        assert_eq!(above, 1);
4762    }
4763}