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