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