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