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