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