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