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