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
819#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
820struct IstanbulPosition {
821 line: u32,
822 col: u32,
823}
824
825impl IstanbulPosition {
826 const fn new(line: u32, col: u32) -> Self {
827 Self { line, col }
828 }
829
830 const fn distance_from(self, target: Self) -> (u32, u32) {
831 (
832 self.line.abs_diff(target.line),
833 self.col.abs_diff(target.col),
834 )
835 }
836}
837
838#[derive(Clone, Copy, Debug, Eq, PartialEq)]
839struct IstanbulSpan {
840 start: IstanbulPosition,
841 end: IstanbulPosition,
842}
843
844impl IstanbulSpan {
845 fn from_entry(fn_entry: &oxc_coverage_instrument::FnEntry) -> Option<Self> {
846 let start = IstanbulPosition::new(fn_entry.loc.start.line, fn_entry.loc.start.column);
847 let end = IstanbulPosition::new(fn_entry.loc.end.line, fn_entry.loc.end.column);
848 (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
849 }
850
851 fn contains(self, position: IstanbulPosition) -> bool {
853 self.start <= position && position < self.end
854 }
855
856 fn strictly_contains(self, other: Self) -> bool {
857 self != other && self.start <= other.start && other.end <= self.end
858 }
859}
860
861#[derive(Clone, Copy, Debug, Eq, PartialEq)]
870struct IstanbulAlias {
871 position: IstanbulPosition,
872 primary: bool,
873}
874
875#[derive(Clone, Copy, Debug, Default)]
878struct IstanbulAliasCounts {
879 primary: usize,
880 secondary: usize,
881}
882
883impl IstanbulAliasCounts {
884 fn add(&mut self, alias: IstanbulAlias) {
885 if alias.primary {
886 self.primary += 1;
887 } else {
888 self.secondary += 1;
889 }
890 }
891
892 const fn is_unique(self, alias: IstanbulAlias) -> bool {
895 if alias.primary {
896 self.primary == 1
897 } else {
898 self.primary == 0 && self.secondary == 1
899 }
900 }
901
902 const fn has_secondary_only_collision(self) -> bool {
903 self.primary == 0 && self.secondary > 1
904 }
905}
906
907fn is_anonymous_istanbul_name(name: &str) -> bool {
908 name.starts_with("(anonymous_")
909}
910
911struct IstanbulFunctionCoverage {
912 name: String,
913 coverage_pct: f64,
914 aliases: Vec<IstanbulAlias>,
915 body_span: Option<IstanbulSpan>,
916}
917
918impl IstanbulFunctionCoverage {
919 fn nearest_alias(
920 &self,
921 target: IstanbulPosition,
922 max_column_drift: Option<u32>,
923 ) -> Option<(u32, u32)> {
924 self.aliases
925 .iter()
926 .filter_map(|alias| {
927 let distance = alias.position.distance_from(target);
928 if distance.0 > 2 {
929 return None;
930 }
931 if distance.0 > 0 && max_column_drift.is_some_and(|maximum| distance.1 > maximum) {
932 return None;
933 }
934 Some(distance)
935 })
936 .min()
937 }
938}
939
940pub struct IstanbulFileCoverage {
943 functions: Vec<IstanbulFunctionCoverage>,
947 alias_index: rustc_hash::FxHashMap<(String, u32, u32), usize>,
952 ambiguous_aliases: rustc_hash::FxHashSet<(String, u32, u32)>,
956 ambiguous_anonymous_aliases: rustc_hash::FxHashSet<IstanbulPosition>,
960 relocated: bool,
964}
965
966impl IstanbulFileCoverage {
967 fn new(mut functions: Vec<IstanbulFunctionCoverage>, relocated: bool) -> Self {
968 let mut named_alias_counts: rustc_hash::FxHashMap<
969 (String, IstanbulPosition),
970 IstanbulAliasCounts,
971 > = rustc_hash::FxHashMap::default();
972 let mut anonymous_alias_counts: rustc_hash::FxHashMap<
973 IstanbulPosition,
974 IstanbulAliasCounts,
975 > = rustc_hash::FxHashMap::default();
976 for function in &functions {
977 let is_anonymous = is_anonymous_istanbul_name(&function.name);
978 for alias in &function.aliases {
979 named_alias_counts
980 .entry((function.name.clone(), alias.position))
981 .or_default()
982 .add(*alias);
983 if is_anonymous {
984 anonymous_alias_counts
985 .entry(alias.position)
986 .or_default()
987 .add(*alias);
988 }
989 }
990 }
991
992 let mut ambiguous_aliases = rustc_hash::FxHashSet::default();
997 let mut ambiguous_anonymous_aliases = rustc_hash::FxHashSet::default();
998 for function in &mut functions {
999 let name = function.name.clone();
1000 let is_anonymous = is_anonymous_istanbul_name(&name);
1001 function.aliases.retain(|alias| {
1002 let named = named_alias_counts
1003 .get(&(name.clone(), alias.position))
1004 .copied()
1005 .unwrap_or_default();
1006 let anonymous = is_anonymous
1007 .then(|| anonymous_alias_counts.get(&alias.position).copied())
1008 .flatten();
1009 let unique = named.is_unique(*alias)
1010 && anonymous.is_none_or(|counts| counts.is_unique(*alias));
1011 if unique {
1012 return true;
1013 }
1014 if alias.primary {
1015 ambiguous_aliases.insert((
1016 name.clone(),
1017 alias.position.line,
1018 alias.position.col,
1019 ));
1020 if anonymous.is_some_and(|counts| counts.primary > 1) {
1021 ambiguous_anonymous_aliases.insert(alias.position);
1022 }
1023 } else {
1024 if named.has_secondary_only_collision() {
1025 ambiguous_aliases.insert((
1026 name.clone(),
1027 alias.position.line,
1028 alias.position.col,
1029 ));
1030 }
1031 if anonymous.is_some_and(IstanbulAliasCounts::has_secondary_only_collision) {
1032 ambiguous_anonymous_aliases.insert(alias.position);
1033 }
1034 }
1035 false
1036 });
1037 }
1038
1039 let mut alias_index = rustc_hash::FxHashMap::default();
1040 for (function_index, function) in functions.iter().enumerate() {
1041 for alias in &function.aliases {
1042 alias_index.insert(
1043 (
1044 function.name.clone(),
1045 alias.position.line,
1046 alias.position.col,
1047 ),
1048 function_index,
1049 );
1050 }
1051 }
1052
1053 Self {
1054 functions,
1055 alias_index,
1056 ambiguous_aliases,
1057 ambiguous_anonymous_aliases,
1058 relocated,
1059 }
1060 }
1061
1062 pub fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
1089 let exact_key = (name.to_string(), line, col);
1090 if self.ambiguous_aliases.contains(&exact_key) {
1091 return None;
1092 }
1093 if let Some(&function_index) = self.alias_index.get(&exact_key) {
1094 return Some(self.functions[function_index].coverage_pct);
1095 }
1096
1097 let target = IstanbulPosition::new(line, col);
1098 if let Some(function) = self
1099 .functions
1100 .iter()
1101 .filter(|function| function.name == name)
1102 .filter_map(|function| {
1103 function
1104 .nearest_alias(target, None)
1105 .map(|distance| (distance, function))
1106 })
1107 .min_by_key(|(distance, _)| *distance)
1108 .map(|(_, function)| function)
1109 {
1110 return Some(function.coverage_pct);
1111 }
1112 if self.relocated
1113 && let Some(pct) = self.unambiguous_named_pct(name)
1114 {
1115 return Some(pct);
1116 }
1117 if self.ambiguous_anonymous_aliases.contains(&target) {
1118 return None;
1119 }
1120
1121 let mut nearest_distance: Option<(u32, u32)> = None;
1122 let mut nearest_functions = Vec::new();
1123 for (function_index, function) in self.functions.iter().enumerate() {
1124 if !is_anonymous_istanbul_name(&function.name) {
1125 continue;
1126 }
1127 let Some(distance) =
1128 function.nearest_alias(target, Some(ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT))
1129 else {
1130 continue;
1131 };
1132 match nearest_distance {
1133 None => {
1134 nearest_distance = Some(distance);
1135 nearest_functions.push(function_index);
1136 }
1137 Some(previous) if distance < previous => {
1138 nearest_distance = Some(distance);
1139 nearest_functions.clear();
1140 nearest_functions.push(function_index);
1141 }
1142 Some(previous) if distance == previous => {
1143 nearest_functions.push(function_index);
1144 }
1145 Some(_) => {}
1146 }
1147 }
1148 match nearest_functions.as_slice() {
1149 [] => None,
1150 [function_index] => Some(self.functions[*function_index].coverage_pct),
1151 tied => self.innermost_anonymous_match(tied, target),
1152 }
1153 }
1154
1155 fn innermost_anonymous_match(&self, tied: &[usize], target: IstanbulPosition) -> Option<f64> {
1156 let containing: Option<Vec<_>> = tied
1157 .iter()
1158 .map(|&function_index| {
1159 self.functions[function_index]
1160 .body_span
1161 .filter(|span| span.contains(target))
1162 .map(|span| (function_index, span))
1163 })
1164 .collect();
1165 let containing = containing?;
1166
1167 let mut winner = None;
1168 for &(function_index, candidate_span) in &containing {
1169 let is_strictly_innermost = containing.iter().all(|&(other_index, other_span)| {
1170 other_index == function_index || other_span.strictly_contains(candidate_span)
1171 });
1172 if !is_strictly_innermost {
1173 continue;
1174 }
1175 if winner.replace(function_index).is_some() {
1176 return None;
1177 }
1178 }
1179 winner.map(|function_index| self.functions[function_index].coverage_pct)
1180 }
1181
1182 fn unambiguous_named_pct(&self, name: &str) -> Option<f64> {
1187 let mut found: Option<f64> = None;
1188 for function in &self.functions {
1189 if function.name != name {
1190 continue;
1191 }
1192 match found {
1193 None => found = Some(function.coverage_pct),
1194 Some(previous) if previous.to_bits() == function.coverage_pct.to_bits() => {}
1195 Some(_) => return None,
1196 }
1197 }
1198 found
1199 }
1200}
1201
1202pub struct IstanbulCoverage {
1204 files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
1205}
1206
1207impl IstanbulCoverage {
1208 pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
1210 self.files.get(path)
1211 }
1212}
1213
1214enum CrapCoverageResolution<'a> {
1222 TemplateInherited(&'a TemplateInheritContext),
1223 Istanbul {
1224 file_coverage: Option<&'a IstanbulFileCoverage>,
1225 },
1226 StaticEstimated,
1227}
1228
1229fn resolve_crap_coverage<'a>(
1230 template_inherit: Option<&'a TemplateInheritContext>,
1231 istanbul_coverage: Option<&'a IstanbulCoverage>,
1232 path: &std::path::Path,
1233) -> CrapCoverageResolution<'a> {
1234 if let Some(inherit_ctx) = template_inherit {
1235 CrapCoverageResolution::TemplateInherited(inherit_ctx)
1236 } else if let Some(istanbul) = istanbul_coverage {
1237 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1238 CrapCoverageResolution::Istanbul {
1239 file_coverage: istanbul.get(&canonical),
1240 }
1241 } else {
1242 CrapCoverageResolution::StaticEstimated
1243 }
1244}
1245
1246pub fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
1254 let candidates = [
1255 root.join("coverage/coverage-final.json"),
1256 root.join(".nyc_output/coverage-final.json"),
1257 ];
1258 candidates.into_iter().find(|p| p.is_file())
1259}
1260
1261pub fn resolve_relative_to_root(
1267 path: &std::path::Path,
1268 project_root: Option<&std::path::Path>,
1269) -> std::path::PathBuf {
1270 if fallow_types::path_util::is_absolute_path_any_platform(path) {
1271 return path.to_path_buf();
1272 }
1273 match project_root {
1274 Some(root) => root.join(path),
1275 None => path.to_path_buf(),
1276 }
1277}
1278
1279pub(super) fn load_istanbul_coverage(
1294 path: &std::path::Path,
1295 coverage_root: Option<&std::path::Path>,
1296 project_root: Option<&std::path::Path>,
1297 relocated: bool,
1298) -> Result<IstanbulCoverage, String> {
1299 super::validate_coverage_root_absolute(coverage_root)?;
1300 let resolved = resolve_relative_to_root(path, project_root);
1301 let file_path = if resolved.is_dir() {
1302 let candidate = resolved.join("coverage-final.json");
1303 if candidate.is_file() {
1304 candidate
1305 } else {
1306 return Err(format!(
1307 "no coverage-final.json found in {}",
1308 resolved.display()
1309 ));
1310 }
1311 } else {
1312 resolved
1313 };
1314
1315 let json = std::fs::read_to_string(&file_path)
1316 .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
1317
1318 let raw: std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage> =
1319 oxc_coverage_instrument::parse_coverage_map(&json).map_err(|e| {
1320 format!(
1321 "failed to parse coverage data from {}: {e}",
1322 file_path.display()
1323 )
1324 })?;
1325
1326 let mut files = rustc_hash::FxHashMap::default();
1327 for file_cov in raw.values() {
1328 let raw_path = std::path::PathBuf::from(&file_cov.path);
1329 let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
1330 rebase_coverage_path(raw_path, cov_root, proj_root)
1331 } else {
1332 raw_path
1333 };
1334 let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
1335
1336 let mut functions = Vec::with_capacity(file_cov.fn_map.len());
1337 for (fn_id, fn_entry) in &file_cov.fn_map {
1338 let coverage_pct = compute_function_statement_coverage(file_cov, fn_id, fn_entry);
1339 functions.push(istanbul_function_coverage(fn_entry, coverage_pct));
1340 }
1341
1342 files.insert(canonical, IstanbulFileCoverage::new(functions, relocated));
1343 }
1344
1345 Ok(IstanbulCoverage { files })
1346}
1347
1348fn rebase_coverage_path(
1356 raw_path: std::path::PathBuf,
1357 coverage_root: &std::path::Path,
1358 project_root: &std::path::Path,
1359) -> std::path::PathBuf {
1360 if let Ok(rel) = raw_path.strip_prefix(coverage_root) {
1361 return project_root.join(rel);
1362 }
1363 if let Ok(canonical) = dunce::canonicalize(&raw_path)
1364 && let Ok(rel) = canonical.strip_prefix(coverage_root)
1365 {
1366 return project_root.join(rel);
1367 }
1368 raw_path
1369}
1370
1371fn istanbul_function_coverage(
1372 fn_entry: &oxc_coverage_instrument::FnEntry,
1373 coverage_pct: f64,
1374) -> IstanbulFunctionCoverage {
1375 let body_span = IstanbulSpan::from_entry(fn_entry);
1376 let candidates = [
1377 Some(IstanbulAlias {
1378 position: IstanbulPosition::new(
1379 effective_istanbul_fn_line(fn_entry),
1380 effective_istanbul_fn_col(fn_entry),
1381 ),
1382 primary: true,
1383 }),
1384 Some(IstanbulAlias {
1385 position: IstanbulPosition::new(fn_entry.decl.start.line, fn_entry.decl.start.column),
1386 primary: true,
1387 }),
1388 body_span.map(|span| IstanbulAlias {
1389 position: span.start,
1390 primary: false,
1391 }),
1392 ];
1393 let mut aliases: Vec<IstanbulAlias> = Vec::with_capacity(candidates.len());
1394 for candidate in candidates.into_iter().flatten() {
1395 if !aliases
1396 .iter()
1397 .any(|alias| alias.position == candidate.position)
1398 {
1399 aliases.push(candidate);
1400 }
1401 }
1402
1403 IstanbulFunctionCoverage {
1404 name: fn_entry.name.clone(),
1405 coverage_pct,
1406 aliases,
1407 body_span,
1408 }
1409}
1410
1411fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
1412 if fn_entry.line > 0 {
1413 fn_entry.line
1414 } else {
1415 fn_entry.decl.start.line
1416 }
1417}
1418
1419fn effective_istanbul_fn_col(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
1424 fn_entry.decl.start.column
1425}
1426
1427fn compute_function_statement_coverage(
1434 file_cov: &oxc_coverage_instrument::FileCoverage,
1435 fn_id: &str,
1436 fn_entry: &oxc_coverage_instrument::FnEntry,
1437) -> f64 {
1438 let fn_start_line = fn_entry.loc.start.line;
1439 let fn_start_col = fn_entry.loc.start.column;
1440 let fn_end_line = fn_entry.loc.end.line;
1441 let fn_end_col = fn_entry.loc.end.column;
1442
1443 let mut total = 0u32;
1444 let mut covered = 0u32;
1445
1446 for (stmt_id, stmt_loc) in &file_cov.statement_map {
1447 let after_start = stmt_loc.start.line > fn_start_line
1448 || (stmt_loc.start.line == fn_start_line && stmt_loc.start.column >= fn_start_col);
1449 let before_end = stmt_loc.end.line < fn_end_line
1450 || (stmt_loc.end.line == fn_end_line && stmt_loc.end.column <= fn_end_col);
1451
1452 if after_start && before_end {
1453 total += 1;
1454 if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
1455 covered += 1;
1456 }
1457 }
1458 }
1459
1460 if total == 0 {
1461 let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
1462 if hit > 0 { 100.0 } else { 0.0 }
1463 } else {
1464 f64::from(covered) / f64::from(total) * 100.0
1465 }
1466}
1467
1468fn count_unused_exports_by_path(
1473 unused_exports: &[crate::results::UnusedExportFinding],
1474) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
1475 let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
1476 for exp in unused_exports {
1477 *map.entry(exp.export.path.as_path()).or_default() += 1;
1478 }
1479 map
1480}
1481
1482fn compute_maintainability_index(
1502 complexity_density: f64,
1503 dead_code_ratio: f64,
1504 fan_out: usize,
1505 lines: u32,
1506) -> f64 {
1507 let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
1508 let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
1509 #[expect(
1510 clippy::suboptimal_flops,
1511 reason = "formula matches documented specification"
1512 )]
1513 let score = 100.0
1514 - (complexity_density * 30.0 * dampening)
1515 - (dead_code_ratio * 20.0)
1516 - fan_out_penalty;
1517 score.clamp(0.0, 100.0)
1518}
1519
1520fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
1521 (100.0 - score.maintainability_index).clamp(0.0, 100.0)
1522}
1523
1524#[must_use]
1530pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
1531 max_crap_threshold <= 0.0 || (score.crap_above_threshold == 0 && score.crap_exempted > 0)
1532}
1533
1534fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
1541 if file_score_fully_crap_exempt(score, max_crap_threshold) {
1542 return 0.0;
1543 }
1544 let crap_max = score.crap_max;
1545 let t = score.crap_effective_threshold.unwrap_or(max_crap_threshold);
1546 let half = t / 2.0;
1547 let saturation = t * 10.0 / 3.0;
1548 if crap_max <= 0.0 {
1549 0.0
1550 } else if crap_max < half {
1551 (crap_max / half) * 45.0
1552 } else if crap_max < t {
1553 ((crap_max - half) / half).mul_add(30.0, 45.0)
1554 } else if crap_max < saturation {
1555 ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
1556 } else {
1557 100.0
1558 }
1559}
1560
1561fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
1562 file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
1563}
1564
1565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1571pub enum FileScoreConcern {
1572 Structural,
1574 Risk,
1576}
1577
1578impl FileScoreConcern {
1579 pub const fn label(self) -> &'static str {
1581 match self {
1582 Self::Structural => "structure",
1583 Self::Risk => "risk",
1584 }
1585 }
1586}
1587
1588pub fn file_score_concern_axis(
1597 score: &FileHealthScore,
1598 max_crap_threshold: f64,
1599) -> FileScoreConcern {
1600 let crap_concern = file_score_crap_concern(score, max_crap_threshold);
1601 if crap_concern <= 0.0 {
1602 FileScoreConcern::Structural
1603 } else if crap_concern >= file_score_structural_concern(score) {
1604 FileScoreConcern::Risk
1605 } else {
1606 FileScoreConcern::Structural
1607 }
1608}
1609
1610fn compare_file_score_triage(
1611 a: &FileHealthScore,
1612 b: &FileHealthScore,
1613 max_crap_threshold: f64,
1614) -> std::cmp::Ordering {
1615 file_score_triage_concern(b, max_crap_threshold)
1616 .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
1617 .then_with(|| b.crap_max.total_cmp(&a.crap_max))
1618 .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
1619 .then_with(|| a.path.cmp(&b.path))
1620}
1621
1622#[derive(Clone, Copy)]
1625pub(super) struct FileScoreComputeInput<'a> {
1626 pub(super) modules: &'a [crate::source::ModuleInfo],
1627 pub(super) file_paths:
1628 &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1629 pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1630 pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
1631 pub(super) root: &'a std::path::Path,
1632 pub(super) crap_thresholds: CrapScoreThresholds<'a>,
1633}
1634
1635pub(super) fn compute_file_scores(
1641 input: FileScoreComputeInput<'_>,
1642 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
1643) -> Result<FileScoreOutput, String> {
1644 let FileScoreComputeInput {
1645 modules,
1646 file_paths,
1647 changed_files,
1648 istanbul_coverage,
1649 root,
1650 crap_thresholds,
1651 } = input;
1652 let retained_graph = analysis_output.graph.ok_or("graph not available")?;
1653 let test_coverage = retained_graph.static_test_coverage();
1654 let graph = retained_graph.as_graph();
1655 let results = &analysis_output.results;
1656
1657 let circular_files = collect_circular_files(results);
1658 let top_complex_fns = collect_top_complex_fns(modules, file_paths);
1659 let cycle_members = collect_cycle_members(results);
1660 let direct_callers = collect_direct_callers(graph, file_paths);
1661 let unused_export_names = collect_unused_export_names(results);
1662
1663 let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
1664 .unused_files
1665 .iter()
1666 .map(|f| f.file.path.as_path())
1667 .collect();
1668
1669 let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
1670
1671 let FileScoreCoverageSetup {
1672 module_by_id,
1673 coverage,
1674 } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
1675
1676 let template_inherit =
1677 build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
1678
1679 let mut acc = accumulate_file_scores(
1680 unused_export_names,
1681 &FileScoreLoopCtx {
1682 graph,
1683 test_coverage,
1684 file_paths,
1685 module_by_id: &module_by_id,
1686 unused_files: &unused_files,
1687 unused_exports_by_path: &unused_exports_by_path,
1688 template_inherit: &template_inherit,
1689 istanbul_coverage,
1690 root,
1691 crap_thresholds,
1692 },
1693 );
1694 acc.scores = finalize_file_score_list(
1695 acc.scores,
1696 changed_files,
1697 crap_thresholds.resolver.global.crap,
1698 );
1699
1700 Ok(build_file_score_output(FileScoreOutputParts {
1701 graph,
1702 file_paths,
1703 results,
1704 scores: acc.scores,
1705 coverage,
1706 circular_files,
1707 top_complex_fns,
1708 entry_points: acc.entry_points,
1709 value_export_counts: acc.value_export_counts,
1710 unused_export_names: acc.unused_export_names,
1711 cycle_members,
1712 direct_callers,
1713 istanbul_matched: acc.istanbul_matched,
1714 istanbul_total: acc.istanbul_total,
1715 per_function_crap: acc.per_function_crap,
1716 template_inherit,
1717 }))
1718}
1719
1720struct FileScoreLoopCtx<'a> {
1722 graph: &'a fallow_graph::graph::ModuleGraph,
1723 test_coverage: StaticTestCoverage<'a>,
1724 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
1725 module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
1726 unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
1727 unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
1728 template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1729 istanbul_coverage: Option<&'a IstanbulCoverage>,
1730 root: &'a std::path::Path,
1733 crap_thresholds: CrapScoreThresholds<'a>,
1734}
1735
1736struct FileScoreAccumulator {
1738 scores: Vec<FileHealthScore>,
1739 entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
1740 value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
1741 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1742 per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
1743 istanbul_matched: usize,
1744 istanbul_total: usize,
1745}
1746
1747impl FileScoreAccumulator {
1748 fn with_capacity(modules: usize) -> Self {
1750 FileScoreAccumulator {
1751 scores: Vec::with_capacity(modules),
1752 entry_points: rustc_hash::FxHashSet::default(),
1753 value_export_counts: rustc_hash::FxHashMap::default(),
1754 unused_export_names: rustc_hash::FxHashMap::default(),
1755 per_function_crap: rustc_hash::FxHashMap::default(),
1756 istanbul_matched: 0,
1757 istanbul_total: 0,
1758 }
1759 }
1760}
1761
1762fn accumulate_file_scores(
1765 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1766 ctx: &FileScoreLoopCtx<'_>,
1767) -> FileScoreAccumulator {
1768 let mut acc = FileScoreAccumulator {
1769 unused_export_names,
1770 ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
1771 };
1772 for node in &ctx.graph.modules {
1773 let Some(path) = ctx.file_paths.get(&node.file_id) else {
1774 continue;
1775 };
1776 record_entry_point(&mut acc.entry_points, node, path);
1777 let score = compute_one_file_score(&mut acc, ctx, node, path);
1778 acc.scores.push(score);
1779 }
1780 acc
1781}
1782
1783fn finalize_file_score_list(
1786 mut scores: Vec<FileHealthScore>,
1787 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1788 max_crap_threshold: f64,
1789) -> Vec<FileHealthScore> {
1790 if let Some(changed) = changed_files {
1791 scores.retain(|s| changed.contains(&s.path));
1792 }
1793 scores.retain(|s| s.function_count > 0);
1794 scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
1795 scores
1796}
1797
1798fn compute_one_file_score(
1800 acc: &mut FileScoreAccumulator,
1801 ctx: &FileScoreLoopCtx<'_>,
1802 node: &fallow_graph::graph::ModuleNode,
1803 path: &std::path::Path,
1804) -> FileHealthScore {
1805 let fan_in = ctx
1806 .graph
1807 .reverse_deps
1808 .get(node.file_id.0 as usize)
1809 .map_or(0, Vec::len);
1810 let fan_out = node.edge_range.len();
1811
1812 let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
1813 .module_by_id
1814 .get(&node.file_id)
1815 .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
1816
1817 let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
1818 let path_owned = path.to_path_buf();
1819 acc.value_export_counts
1820 .insert(path_owned.clone(), value_exports);
1821 record_unused_file_export_names(
1822 path_owned.as_path(),
1823 &node.exports,
1824 ctx.unused_files,
1825 &mut acc.unused_export_names,
1826 );
1827
1828 let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
1829 compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
1830
1831 let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
1832 let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative);
1833 let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
1834 acc.istanbul_matched += crap.istanbul_matched;
1835 acc.istanbul_total += crap.istanbul_total;
1836 record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
1837
1838 let global_crap = ctx.crap_thresholds.resolver.global.crap;
1843 let crap_effective_threshold = crap
1844 .signals
1845 .min_ceiling
1846 .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
1847
1848 FileHealthScore {
1849 path: path_owned,
1850 fan_in,
1851 fan_out,
1852 dead_code_ratio: dead_code_ratio_rounded,
1853 complexity_density: complexity_density_rounded,
1854 maintainability_index: maintainability_index_rounded,
1855 total_cyclomatic,
1856 total_cognitive,
1857 function_count,
1858 lines,
1859 crap_max: crap.max,
1860 crap_above_threshold: crap.signals.above,
1861 crap_exempted: crap.signals.exempted,
1862 crap_effective_threshold,
1863 }
1864}
1865
1866fn compute_file_score_metrics(
1869 node: &fallow_graph::graph::ModuleNode,
1870 path: &std::path::Path,
1871 ctx: &FileScoreLoopCtx<'_>,
1872 total_cyclomatic: u32,
1873 lines: u32,
1874 fan_out: usize,
1875) -> (f64, f64, f64) {
1876 let dead_code_ratio = compute_dead_code_ratio(
1877 path,
1878 &node.exports,
1879 ctx.unused_files,
1880 ctx.unused_exports_by_path,
1881 );
1882 let complexity_density = compute_complexity_density(total_cyclomatic, lines);
1883
1884 let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
1885 let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
1886
1887 let maintainability_index = compute_maintainability_index(
1888 complexity_density_rounded,
1889 dead_code_ratio_rounded,
1890 fan_out,
1891 lines,
1892 );
1893 (
1894 dead_code_ratio_rounded,
1895 complexity_density_rounded,
1896 (maintainability_index * 10.0).round() / 10.0,
1897 )
1898}
1899
1900fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
1901 let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
1902 let unused_deps = parts.results.unused_dependencies.len()
1903 + parts.results.unused_dev_dependencies.len()
1904 + parts.results.unused_optional_dependencies.len();
1905 let analysis_snapshot =
1906 build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
1907 let analysis_counts =
1908 build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
1909 let template_inherit_provenance =
1910 build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
1911
1912 FileScoreOutput {
1913 scores: parts.scores,
1914 coverage: parts.coverage,
1915 circular_files: parts.circular_files,
1916 top_complex_fns: parts.top_complex_fns,
1917 entry_points: parts.entry_points,
1918 value_export_counts: parts.value_export_counts,
1919 unused_export_names: parts.unused_export_names,
1920 cycle_members: parts.cycle_members,
1921 direct_callers: parts.direct_callers,
1922 analysis_counts,
1923 prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
1924 render_fan_in: parts.results.render_fan_in.clone(),
1925 analysis_snapshot,
1926 istanbul_matched: parts.istanbul_matched,
1927 istanbul_total: parts.istanbul_total,
1928 per_function_crap: parts.per_function_crap,
1929 template_inherit_provenance,
1930 }
1931}
1932
1933fn build_file_score_analysis_counts(
1934 results: &crate::results::AnalysisResults,
1935 total_exports: usize,
1936 unused_deps: usize,
1937) -> crate::vital_signs::AnalysisCounts {
1938 crate::vital_signs::AnalysisCounts {
1939 total_exports,
1940 dead_files: results.unused_files.len(),
1941 dead_exports: results.unused_exports.len() + results.unused_types.len(),
1942 unused_deps,
1943 circular_deps: results.circular_dependencies.len(),
1944 total_deps: 0usize,
1945 }
1946}
1947
1948fn build_template_inherit_provenance(
1949 template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
1950 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
1951) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
1952 template_inherit
1953 .into_iter()
1954 .filter_map(|(file_id, ctx)| {
1955 file_paths
1956 .get(&file_id)
1957 .map(|path| ((**path).clone(), ctx.provenance_owner))
1958 })
1959 .collect()
1960}
1961
1962fn record_entry_point(
1963 entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
1964 node: &fallow_graph::graph::ModuleNode,
1965 path: &std::path::Path,
1966) {
1967 if node.is_entry_point() {
1968 entry_points.insert(path.to_path_buf());
1969 }
1970}
1971
1972fn record_unused_file_export_names(
1973 path: &std::path::Path,
1974 exports: &[fallow_graph::graph::ExportSymbol],
1975 unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
1976 unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
1977) {
1978 if !unused_files.contains(path) || unused_export_names.contains_key(path) {
1979 return;
1980 }
1981
1982 let names: Vec<String> = exports
1983 .iter()
1984 .filter(|export| !export.is_type_only)
1985 .map(|export| export.name.to_string())
1986 .collect();
1987 if !names.is_empty() {
1988 unused_export_names.insert(path.to_path_buf(), names);
1989 }
1990}
1991
1992struct FileScoreCrap {
1993 max: f64,
1994 signals: CrapThresholdSignals,
1995 per_function: Vec<PerFunctionCrap>,
1996 istanbul_matched: usize,
1997 istanbul_total: usize,
1998}
1999
2000impl FileScoreCrap {
2001 fn empty() -> Self {
2002 Self {
2003 max: 0.0,
2004 signals: CrapThresholdSignals::default(),
2005 per_function: Vec::new(),
2006 istanbul_matched: 0,
2007 istanbul_total: 0,
2008 }
2009 }
2010
2011 fn estimated(result: EstimatedCrapResult) -> Self {
2012 Self {
2013 max: result.max_crap,
2014 signals: result.signals,
2015 per_function: result.per_function,
2016 istanbul_matched: 0,
2017 istanbul_total: 0,
2018 }
2019 }
2020
2021 fn istanbul(result: IstanbulCrapResult) -> Self {
2022 Self {
2023 max: result.max_crap,
2024 signals: result.signals,
2025 per_function: result.per_function,
2026 istanbul_matched: result.matched,
2027 istanbul_total: result.total,
2028 }
2029 }
2030}
2031
2032fn compute_file_score_crap(
2033 node: &fallow_graph::graph::ModuleNode,
2034 ctx: &FileScoreLoopCtx<'_>,
2035 path: &std::path::Path,
2036 ceilings: &CrapCeilingLookup<'_>,
2037) -> FileScoreCrap {
2038 let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
2039 return FileScoreCrap::empty();
2040 };
2041
2042 let is_coverage_suppressed = crate::suppress::is_file_suppressed(
2043 &module.suppressions,
2044 fallow_types::suppress::IssueKind::CoverageGaps,
2045 );
2046 let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
2047 let resolution = resolve_crap_coverage(
2048 ctx.template_inherit.get(&node.file_id),
2049 ctx.istanbul_coverage,
2050 path,
2051 );
2052 match resolution {
2053 CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
2054 compute_template_inherited_crap(module, inherit_ctx, ceilings)
2055 }
2056 CrapCoverageResolution::Istanbul { file_coverage } => {
2057 compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
2058 }
2059 CrapCoverageResolution::StaticEstimated => compute_static_file_crap(
2060 module,
2061 &node.exports,
2062 ctx.test_coverage,
2063 is_test_reachable,
2064 ceilings,
2065 ),
2066 }
2067}
2068
2069fn compute_template_inherited_crap(
2070 module: &crate::source::ModuleInfo,
2071 inherit_ctx: &TemplateInheritContext,
2072 ceilings: &CrapCeilingLookup<'_>,
2073) -> FileScoreCrap {
2074 FileScoreCrap::estimated(compute_crap_scores_estimated(
2075 &module.complexity,
2076 &inherit_ctx.test_referenced_exports,
2077 inherit_ctx.is_test_reachable,
2078 fallow_output::CoverageSource::EstimatedComponentInherited,
2079 ceilings,
2080 ))
2081}
2082
2083fn compute_istanbul_file_crap(
2084 module: &crate::source::ModuleInfo,
2085 file_coverage: Option<&IstanbulFileCoverage>,
2086 is_test_reachable: bool,
2087 ceilings: &CrapCeilingLookup<'_>,
2088) -> FileScoreCrap {
2089 FileScoreCrap::istanbul(compute_crap_scores_istanbul(
2090 &module.complexity,
2091 file_coverage,
2092 is_test_reachable,
2093 ceilings,
2094 ))
2095}
2096
2097fn compute_static_file_crap(
2098 module: &crate::source::ModuleInfo,
2099 exports: &[fallow_graph::graph::ExportSymbol],
2100 test_coverage: StaticTestCoverage<'_>,
2101 is_test_reachable: bool,
2102 ceilings: &CrapCeilingLookup<'_>,
2103) -> FileScoreCrap {
2104 let test_refs = build_test_referenced_exports(exports, test_coverage);
2105 FileScoreCrap::estimated(compute_crap_scores_estimated(
2106 &module.complexity,
2107 &test_refs,
2108 is_test_reachable,
2109 fallow_output::CoverageSource::Estimated,
2110 ceilings,
2111 ))
2112}
2113
2114fn record_per_function_crap(
2115 per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2116 path: &std::path::Path,
2117 per_function: Vec<PerFunctionCrap>,
2118) {
2119 if !per_function.is_empty() {
2120 per_function_crap.insert(path.to_path_buf(), per_function);
2121 }
2122}
2123
2124struct FileScoreCoverageSetup<'a> {
2125 module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2126 coverage: CoverageGapData,
2127}
2128
2129fn prepare_file_score_coverage_setup<'a>(
2130 modules: &'a [crate::source::ModuleInfo],
2131 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2132 results: &crate::results::AnalysisResults,
2133 graph: &fallow_graph::graph::ModuleGraph,
2134 test_coverage: StaticTestCoverage<'_>,
2135 root: &std::path::Path,
2136) -> FileScoreCoverageSetup<'a> {
2137 let module_by_id: rustc_hash::FxHashMap<_, _> =
2138 modules.iter().map(|m| (m.file_id, m)).collect();
2139 let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
2140 .unused_exports
2141 .iter()
2142 .map(|export| {
2143 (
2144 export.export.path.as_path(),
2145 export.export.export_name.clone(),
2146 )
2147 })
2148 .collect();
2149 let coverage = compute_coverage_gaps(
2150 graph,
2151 test_coverage,
2152 file_paths,
2153 &module_by_id,
2154 &unused_exports,
2155 root,
2156 );
2157 FileScoreCoverageSetup {
2158 module_by_id,
2159 coverage,
2160 }
2161}
2162
2163fn collect_circular_files(
2164 results: &crate::results::AnalysisResults,
2165) -> rustc_hash::FxHashSet<std::path::PathBuf> {
2166 results
2167 .circular_dependencies
2168 .iter()
2169 .flat_map(|c| c.cycle.files.iter().cloned())
2170 .collect()
2171}
2172
2173fn collect_top_complex_fns(
2174 modules: &[crate::source::ModuleInfo],
2175 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2176) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
2177 let mut top_complex_fns = rustc_hash::FxHashMap::default();
2178 for module in modules {
2179 if module.complexity.is_empty() {
2180 continue;
2181 }
2182 let Some(path) = file_paths.get(&module.file_id) else {
2183 continue;
2184 };
2185 let mut funcs: Vec<(String, u32, u16)> = module
2186 .complexity
2187 .iter()
2188 .map(|f| (f.name.clone(), f.line, f.cognitive))
2189 .collect();
2190 funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
2191 funcs.truncate(3);
2192 if funcs[0].2 > 0 {
2193 top_complex_fns.insert((*path).clone(), funcs);
2194 }
2195 }
2196 top_complex_fns
2197}
2198
2199fn collect_cycle_members(
2200 results: &crate::results::AnalysisResults,
2201) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
2202 let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
2203 rustc_hash::FxHashMap::default();
2204 for cycle in &results.circular_dependencies {
2205 for file in &cycle.cycle.files {
2206 let others: Vec<std::path::PathBuf> = cycle
2207 .cycle
2208 .files
2209 .iter()
2210 .filter(|f| *f != file)
2211 .cloned()
2212 .collect();
2213 cycle_members
2214 .entry(file.clone())
2215 .or_default()
2216 .extend(others);
2217 }
2218 }
2219 for members in cycle_members.values_mut() {
2220 members.sort();
2221 members.dedup();
2222 }
2223 cycle_members
2224}
2225
2226fn collect_unused_export_names(
2227 results: &crate::results::AnalysisResults,
2228) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
2229 let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
2230 rustc_hash::FxHashMap::default();
2231 for exp in &results.unused_exports {
2232 unused_export_names
2233 .entry(exp.export.path.clone())
2234 .or_default()
2235 .push(exp.export.export_name.clone());
2236 }
2237 unused_export_names
2238}
2239
2240fn build_analysis_counts_snapshot(
2241 graph: &fallow_graph::graph::ModuleGraph,
2242 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2243 results: &crate::results::AnalysisResults,
2244 unused_deps: usize,
2245) -> AnalysisCountsSnapshot {
2246 let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
2247 graph.modules.len(),
2248 rustc_hash::FxBuildHasher,
2249 );
2250 for module in &graph.modules {
2251 if let Some(path) = file_paths.get(&module.file_id) {
2252 module_export_counts.insert((*path).clone(), module.exports.len());
2253 }
2254 }
2255
2256 let mut unused_export_paths =
2257 Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
2258 unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
2259 unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
2260
2261 let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
2262 unused_dep_package_paths.extend(
2263 results
2264 .unused_dependencies
2265 .iter()
2266 .map(|d| d.dep.path.clone()),
2267 );
2268 unused_dep_package_paths.extend(
2269 results
2270 .unused_dev_dependencies
2271 .iter()
2272 .map(|d| d.dep.path.clone()),
2273 );
2274 unused_dep_package_paths.extend(
2275 results
2276 .unused_optional_dependencies
2277 .iter()
2278 .map(|d| d.dep.path.clone()),
2279 );
2280
2281 AnalysisCountsSnapshot {
2282 unused_file_paths: results
2283 .unused_files
2284 .iter()
2285 .map(|f| f.file.path.clone())
2286 .collect(),
2287 unused_export_paths,
2288 unused_dep_package_paths,
2289 circular_dep_groups: results
2290 .circular_dependencies
2291 .iter()
2292 .map(|c| c.cycle.files.clone())
2293 .collect(),
2294 module_export_counts,
2295 }
2296}
2297
2298#[cfg(test)]
2299mod tests {
2300 use super::super::threshold_overrides::GlobalHealthThresholds;
2301 use super::*;
2302
2303 fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
2306 ThresholdOverrideResolver::new(
2307 &[],
2308 GlobalHealthThresholds {
2309 cyclomatic: 20,
2310 cognitive: 15,
2311 crap,
2312 unit_size: 120,
2313 },
2314 )
2315 }
2316
2317 fn test_override_resolver(
2319 overrides: &[fallow_config::HealthThresholdOverride],
2320 ) -> ThresholdOverrideResolver {
2321 ThresholdOverrideResolver::new(
2322 overrides,
2323 GlobalHealthThresholds {
2324 cyclomatic: 20,
2325 cognitive: 15,
2326 crap: CRAP_THRESHOLD,
2327 unit_size: 120,
2328 },
2329 )
2330 }
2331
2332 fn istanbul_crap_default(
2334 complexity: &[fallow_types::extract::FunctionComplexity],
2335 file_coverage: Option<&IstanbulFileCoverage>,
2336 is_test_reachable: bool,
2337 ) -> IstanbulCrapResult {
2338 let resolver = test_crap_resolver(CRAP_THRESHOLD);
2339 let ceilings = CrapCeilingLookup::new(
2340 CrapScoreThresholds {
2341 resolver: &resolver,
2342 enforce_crap: true,
2343 },
2344 std::path::Path::new("src/test.ts"),
2345 );
2346 compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
2347 }
2348
2349 fn test_istanbul_file_coverage(
2350 functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
2351 relocated: bool,
2352 ) -> IstanbulFileCoverage {
2353 let functions = functions
2354 .into_iter()
2355 .map(
2356 |((name, line, col), coverage_pct)| IstanbulFunctionCoverage {
2357 name,
2358 coverage_pct,
2359 aliases: vec![primary_alias(line, col)],
2360 body_span: None,
2361 },
2362 )
2363 .collect();
2364 IstanbulFileCoverage::new(functions, relocated)
2365 }
2366
2367 fn primary_alias(line: u32, col: u32) -> IstanbulAlias {
2368 IstanbulAlias {
2369 position: IstanbulPosition::new(line, col),
2370 primary: true,
2371 }
2372 }
2373
2374 fn secondary_alias(line: u32, col: u32) -> IstanbulAlias {
2375 IstanbulAlias {
2376 position: IstanbulPosition::new(line, col),
2377 primary: false,
2378 }
2379 }
2380
2381 fn body_span(start: (u32, u32), end: (u32, u32)) -> IstanbulSpan {
2382 IstanbulSpan {
2383 start: IstanbulPosition::new(start.0, start.1),
2384 end: IstanbulPosition::new(end.0, end.1),
2385 }
2386 }
2387
2388 fn estimated_crap_default(
2390 complexity: &[fallow_types::extract::FunctionComplexity],
2391 test_referenced_exports: &rustc_hash::FxHashSet<String>,
2392 is_test_reachable: bool,
2393 coverage_source: fallow_output::CoverageSource,
2394 ) -> EstimatedCrapResult {
2395 let resolver = test_crap_resolver(CRAP_THRESHOLD);
2396 let ceilings = CrapCeilingLookup::new(
2397 CrapScoreThresholds {
2398 resolver: &resolver,
2399 enforce_crap: true,
2400 },
2401 std::path::Path::new("src/test.ts"),
2402 );
2403 compute_crap_scores_estimated(
2404 complexity,
2405 test_referenced_exports,
2406 is_test_reachable,
2407 coverage_source,
2408 &ceilings,
2409 )
2410 }
2411
2412 fn compute_file_scores_default(
2414 modules: &[crate::source::ModuleInfo],
2415 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2416 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2417 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2418 istanbul_coverage: Option<&IstanbulCoverage>,
2419 root: &std::path::Path,
2420 ) -> Result<FileScoreOutput, String> {
2421 let resolver = test_crap_resolver(CRAP_THRESHOLD);
2422 compute_file_scores(
2423 FileScoreComputeInput {
2424 modules,
2425 file_paths,
2426 changed_files,
2427 istanbul_coverage,
2428 root,
2429 crap_thresholds: CrapScoreThresholds {
2430 resolver: &resolver,
2431 enforce_crap: true,
2432 },
2433 },
2434 analysis_output,
2435 )
2436 }
2437
2438 #[test]
2439 fn maintainability_perfect_score() {
2440 assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
2441 }
2442
2443 #[test]
2444 fn crap_resolution_prefers_template_inheritance_over_istanbul() {
2445 let inherit_ctx = TemplateInheritContext {
2446 is_test_reachable: true,
2447 test_referenced_exports: rustc_hash::FxHashSet::default(),
2448 provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
2449 };
2450 let istanbul = IstanbulCoverage {
2451 files: rustc_hash::FxHashMap::default(),
2452 };
2453
2454 let resolution = resolve_crap_coverage(
2455 Some(&inherit_ctx),
2456 Some(&istanbul),
2457 std::path::Path::new("/project/src/app.component.html"),
2458 );
2459
2460 assert!(matches!(
2461 resolution,
2462 CrapCoverageResolution::TemplateInherited(_)
2463 ));
2464 }
2465
2466 #[test]
2467 fn crap_resolution_keeps_istanbul_when_file_is_missing() {
2468 let istanbul = IstanbulCoverage {
2469 files: rustc_hash::FxHashMap::default(),
2470 };
2471
2472 let resolution = resolve_crap_coverage(
2473 None,
2474 Some(&istanbul),
2475 std::path::Path::new("/project/src/missing.ts"),
2476 );
2477
2478 assert!(matches!(
2479 resolution,
2480 CrapCoverageResolution::Istanbul {
2481 file_coverage: None
2482 }
2483 ));
2484 }
2485
2486 #[test]
2487 fn maintainability_clamped_at_zero() {
2488 assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
2489 }
2490
2491 #[test]
2492 fn maintainability_formula_correct() {
2493 let result = compute_maintainability_index(0.5, 0.3, 10, 100);
2494 let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
2495 assert!((result - expected).abs() < 0.01);
2496 }
2497
2498 #[test]
2499 fn maintainability_dead_file_penalty() {
2500 let result = compute_maintainability_index(0.0, 1.0, 0, 100);
2501 assert!((result - 80.0).abs() < f64::EPSILON);
2502 }
2503
2504 #[test]
2505 fn maintainability_fan_out_is_logarithmic() {
2506 let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
2507 let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
2508 let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
2509
2510 assert!(result_10 > 90.0); assert!(result_100 > 84.0); assert!((result_100 - result_200).abs() < f64::EPSILON);
2513 }
2514
2515 #[test]
2516 fn maintainability_fan_out_capped_at_15() {
2517 let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
2518 assert!((result - 65.0).abs() < f64::EPSILON);
2519 }
2520
2521 #[test]
2522 fn maintainability_small_file_dampened() {
2523 let small = compute_maintainability_index(0.40, 0.0, 0, 5);
2524 assert!((small - 98.8).abs() < 0.01);
2525 }
2526
2527 #[test]
2528 fn maintainability_large_file_undampened() {
2529 let large = compute_maintainability_index(0.30, 0.0, 0, 192);
2530 assert!((large - 91.0).abs() < 0.01);
2531 }
2532
2533 #[test]
2534 fn maintainability_small_file_ranks_better_than_complex_large_file() {
2535 let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
2536 let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
2537 assert!(
2538 trivial > nightmare,
2539 "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
2540 );
2541 }
2542
2543 #[test]
2544 fn maintainability_at_dampening_boundary() {
2545 let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
2546 let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
2547 assert!((at_boundary - above_boundary).abs() < 0.01);
2548 }
2549
2550 #[test]
2551 fn maintainability_zero_lines_zero_density_penalty() {
2552 let result = compute_maintainability_index(5.0, 0.0, 0, 0);
2553 assert!((result - 100.0).abs() < f64::EPSILON);
2554 }
2555
2556 #[test]
2557 fn complexity_density_zero_lines() {
2558 assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
2559 }
2560
2561 #[test]
2562 fn complexity_density_normal() {
2563 let result = compute_complexity_density(10, 100);
2564 assert!((result - 0.1).abs() < f64::EPSILON);
2565 }
2566
2567 #[test]
2568 fn complexity_density_high() {
2569 let result = compute_complexity_density(50, 10);
2570 assert!((result - 5.0).abs() < f64::EPSILON);
2571 }
2572
2573 #[test]
2574 fn dead_code_ratio_no_exports() {
2575 let unused_files = rustc_hash::FxHashSet::default();
2576 let unused_map = rustc_hash::FxHashMap::default();
2577 let path = std::path::Path::new("/src/foo.ts");
2578 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
2579
2580 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2581 assert!((ratio).abs() < f64::EPSILON);
2582 }
2583
2584 #[test]
2585 fn dead_code_ratio_all_unused_file() {
2586 let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
2587 rustc_hash::FxHashSet::default();
2588 let path = std::path::Path::new("/src/foo.ts");
2589 unused_files.insert(path);
2590 let unused_map = rustc_hash::FxHashMap::default();
2591 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
2592
2593 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2594 assert!((ratio - 1.0).abs() < f64::EPSILON);
2595 }
2596
2597 #[test]
2598 fn dead_code_ratio_mix() {
2599 let unused_files = rustc_hash::FxHashSet::default();
2600 let path = std::path::Path::new("/src/foo.ts");
2601
2602 let exports = vec![
2603 fallow_graph::graph::ExportSymbol {
2604 name: crate::source::ExportName::Named("a".into()),
2605 is_type_only: false,
2606 is_side_effect_used: false,
2607 visibility: crate::source::VisibilityTag::None,
2608 expected_unused_reason: None,
2609 span: oxc_span::Span::empty(0),
2610 references: vec![],
2611 reference_paths: Vec::new(),
2612 members: vec![],
2613 },
2614 fallow_graph::graph::ExportSymbol {
2615 name: crate::source::ExportName::Named("b".into()),
2616 is_type_only: false,
2617 is_side_effect_used: false,
2618 visibility: crate::source::VisibilityTag::None,
2619 expected_unused_reason: None,
2620 span: oxc_span::Span::empty(0),
2621 references: vec![],
2622 reference_paths: Vec::new(),
2623 members: vec![],
2624 },
2625 fallow_graph::graph::ExportSymbol {
2626 name: crate::source::ExportName::Named("c".into()),
2627 is_type_only: false,
2628 is_side_effect_used: false,
2629 visibility: crate::source::VisibilityTag::None,
2630 expected_unused_reason: None,
2631 span: oxc_span::Span::empty(0),
2632 references: vec![],
2633 reference_paths: Vec::new(),
2634 members: vec![],
2635 },
2636 fallow_graph::graph::ExportSymbol {
2637 name: crate::source::ExportName::Named("MyType".into()),
2638 is_type_only: true,
2639 is_side_effect_used: false,
2640 visibility: crate::source::VisibilityTag::None,
2641 expected_unused_reason: None,
2642 span: oxc_span::Span::empty(0),
2643 references: vec![],
2644 reference_paths: Vec::new(),
2645 members: vec![],
2646 },
2647 ];
2648
2649 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2650 rustc_hash::FxHashMap::default();
2651 unused_map.insert(path, 2);
2652
2653 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2654 assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
2655 }
2656
2657 #[test]
2658 fn dead_code_ratio_all_type_only_exports() {
2659 let unused_files = rustc_hash::FxHashSet::default();
2660 let path = std::path::Path::new("/src/types.ts");
2661
2662 let exports = vec![fallow_graph::graph::ExportSymbol {
2663 name: crate::source::ExportName::Named("Foo".into()),
2664 is_type_only: true,
2665 is_side_effect_used: false,
2666 visibility: crate::source::VisibilityTag::None,
2667 expected_unused_reason: None,
2668 span: oxc_span::Span::empty(0),
2669 references: vec![],
2670 reference_paths: Vec::new(),
2671 members: vec![],
2672 }];
2673 let unused_map = rustc_hash::FxHashMap::default();
2674
2675 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2676 assert!((ratio).abs() < f64::EPSILON);
2677 }
2678
2679 #[test]
2680 fn aggregate_complexity_empty_module() {
2681 let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
2682
2683 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2684 assert_eq!(cyc, 0);
2685 assert_eq!(cog, 0);
2686 assert_eq!(funcs, 0);
2687 assert_eq!(lines, 0);
2688 }
2689
2690 #[test]
2691 fn aggregate_complexity_single_function() {
2692 let module = crate::source::ModuleInfo {
2693 line_offsets: vec![0, 10, 20, 30, 40], complexity: vec![fallow_types::extract::FunctionComplexity {
2695 name: "doStuff".into(),
2696 line: 1,
2697 col: 0,
2698 cyclomatic: 7,
2699 cognitive: 4,
2700 line_count: 5,
2701 param_count: 0,
2702 react_hook_count: 0,
2703 react_jsx_max_depth: 0,
2704 react_prop_count: 0,
2705 source_hash: None,
2706 contributions: Vec::new(),
2707 }],
2708 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
2709 };
2710
2711 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2712 assert_eq!(cyc, 7);
2713 assert_eq!(cog, 4);
2714 assert_eq!(funcs, 1);
2715 assert_eq!(lines, 5);
2716 }
2717
2718 #[test]
2719 fn aggregate_complexity_multiple_functions() {
2720 let module = crate::source::ModuleInfo {
2721 line_offsets: vec![0, 10, 20], complexity: vec![
2723 fallow_types::extract::FunctionComplexity {
2724 name: "a".into(),
2725 line: 1,
2726 col: 0,
2727 cyclomatic: 3,
2728 cognitive: 2,
2729 line_count: 1,
2730 param_count: 0,
2731 react_hook_count: 0,
2732 react_jsx_max_depth: 0,
2733 react_prop_count: 0,
2734 source_hash: None,
2735 contributions: Vec::new(),
2736 },
2737 fallow_types::extract::FunctionComplexity {
2738 name: "b".into(),
2739 line: 2,
2740 col: 0,
2741 cyclomatic: 5,
2742 cognitive: 8,
2743 line_count: 2,
2744 param_count: 0,
2745 react_hook_count: 0,
2746 react_jsx_max_depth: 0,
2747 react_prop_count: 0,
2748 source_hash: None,
2749 contributions: Vec::new(),
2750 },
2751 ],
2752 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
2753 };
2754
2755 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
2756 assert_eq!(cyc, 8);
2757 assert_eq!(cog, 10);
2758 assert_eq!(funcs, 2);
2759 assert_eq!(lines, 3);
2760 }
2761
2762 #[test]
2763 fn count_unused_exports_empty() {
2764 let exports: Vec<crate::results::UnusedExportFinding> = vec![];
2765 let map = count_unused_exports_by_path(&exports);
2766 assert!(map.is_empty());
2767 }
2768
2769 #[test]
2770 fn count_unused_exports_groups_by_path() {
2771 let exports = vec![
2772 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2773 path: std::path::PathBuf::from("/src/a.ts"),
2774 export_name: "foo".into(),
2775 is_type_only: false,
2776 line: 1,
2777 col: 0,
2778 span_start: 0,
2779 is_re_export: false,
2780 }),
2781 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2782 path: std::path::PathBuf::from("/src/a.ts"),
2783 export_name: "bar".into(),
2784 is_type_only: false,
2785 line: 5,
2786 col: 0,
2787 span_start: 40,
2788 is_re_export: false,
2789 }),
2790 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
2791 path: std::path::PathBuf::from("/src/b.ts"),
2792 export_name: "baz".into(),
2793 is_type_only: false,
2794 line: 1,
2795 col: 0,
2796 span_start: 0,
2797 is_re_export: false,
2798 }),
2799 ];
2800 let map = count_unused_exports_by_path(&exports);
2801 assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
2802 assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
2803 }
2804
2805 #[test]
2806 fn dead_code_ratio_all_value_exports_unused() {
2807 let unused_files = rustc_hash::FxHashSet::default();
2808 let path = std::path::Path::new("/src/foo.ts");
2809
2810 let exports = vec![
2811 fallow_graph::graph::ExportSymbol {
2812 name: crate::source::ExportName::Named("a".into()),
2813 is_type_only: false,
2814 is_side_effect_used: false,
2815 visibility: crate::source::VisibilityTag::None,
2816 expected_unused_reason: None,
2817 span: oxc_span::Span::empty(0),
2818 references: vec![],
2819 reference_paths: Vec::new(),
2820 members: vec![],
2821 },
2822 fallow_graph::graph::ExportSymbol {
2823 name: crate::source::ExportName::Named("b".into()),
2824 is_type_only: false,
2825 is_side_effect_used: false,
2826 visibility: crate::source::VisibilityTag::None,
2827 expected_unused_reason: None,
2828 span: oxc_span::Span::empty(0),
2829 references: vec![],
2830 reference_paths: Vec::new(),
2831 members: vec![],
2832 },
2833 ];
2834
2835 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2836 rustc_hash::FxHashMap::default();
2837 unused_map.insert(path, 2);
2838
2839 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2840 assert!((ratio - 1.0).abs() < f64::EPSILON);
2841 }
2842
2843 #[test]
2844 fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
2845 let unused_files = rustc_hash::FxHashSet::default();
2846 let path = std::path::Path::new("/src/foo.ts");
2847
2848 let exports = vec![fallow_graph::graph::ExportSymbol {
2849 name: crate::source::ExportName::Named("a".into()),
2850 is_type_only: false,
2851 is_side_effect_used: false,
2852 visibility: crate::source::VisibilityTag::None,
2853 expected_unused_reason: None,
2854 span: oxc_span::Span::empty(0),
2855 references: vec![],
2856 reference_paths: Vec::new(),
2857 members: vec![],
2858 }];
2859
2860 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
2861 rustc_hash::FxHashMap::default();
2862 unused_map.insert(path, 5);
2863
2864 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2865 assert!((ratio - 1.0).abs() < f64::EPSILON);
2866 }
2867
2868 #[test]
2869 fn dead_code_ratio_no_unused_exports_for_path() {
2870 let unused_files = rustc_hash::FxHashSet::default();
2871 let path = std::path::Path::new("/src/clean.ts");
2872
2873 let exports = vec![fallow_graph::graph::ExportSymbol {
2874 name: crate::source::ExportName::Named("used".into()),
2875 is_type_only: false,
2876 is_side_effect_used: false,
2877 visibility: crate::source::VisibilityTag::None,
2878 expected_unused_reason: None,
2879 span: oxc_span::Span::empty(0),
2880 references: vec![],
2881 reference_paths: Vec::new(),
2882 members: vec![],
2883 }];
2884
2885 let unused_map = rustc_hash::FxHashMap::default();
2886 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
2887 assert!(ratio.abs() < f64::EPSILON);
2888 }
2889
2890 #[test]
2891 fn complexity_density_zero_cyclomatic_with_lines() {
2892 let result = compute_complexity_density(0, 100);
2893 assert!(result.abs() < f64::EPSILON);
2894 }
2895
2896 #[test]
2897 fn complexity_density_single_line() {
2898 let result = compute_complexity_density(1, 1);
2899 assert!((result - 1.0).abs() < f64::EPSILON);
2900 }
2901
2902 #[test]
2903 fn maintainability_only_complexity_penalty() {
2904 let result = compute_maintainability_index(3.0, 0.0, 0, 100);
2905 assert!((result - 10.0).abs() < f64::EPSILON);
2906 }
2907
2908 #[test]
2909 fn maintainability_only_dead_code_penalty() {
2910 let result = compute_maintainability_index(0.0, 0.5, 0, 100);
2911 assert!((result - 90.0).abs() < f64::EPSILON);
2912 }
2913
2914 #[test]
2915 fn maintainability_fan_out_one() {
2916 let result = compute_maintainability_index(0.0, 0.0, 1, 100);
2917 let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
2918 assert!((result - expected).abs() < 0.01);
2919 }
2920
2921 #[test]
2922 fn maintainability_all_penalties_maxed() {
2923 let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
2924 assert!(result.abs() < f64::EPSILON);
2925 }
2926
2927 #[test]
2928 fn count_unused_exports_single_file_single_export() {
2929 let exports = vec![crate::results::UnusedExportFinding::with_actions(
2930 crate::results::UnusedExport {
2931 path: std::path::PathBuf::from("/src/only.ts"),
2932 export_name: "lonely".into(),
2933 is_type_only: false,
2934 line: 1,
2935 col: 0,
2936 span_start: 0,
2937 is_re_export: false,
2938 },
2939 )];
2940 let map = count_unused_exports_by_path(&exports);
2941 assert_eq!(map.len(), 1);
2942 assert_eq!(
2943 map.get(std::path::Path::new("/src/only.ts")).copied(),
2944 Some(1)
2945 );
2946 }
2947
2948 fn build_test_graph(
2950 files: &[crate::discover::DiscoveredFile],
2951 entry_point_paths: &[std::path::PathBuf],
2952 resolved_modules: &[fallow_graph::resolve::ResolvedModule],
2953 ) -> fallow_graph::graph::ModuleGraph {
2954 let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
2955 .iter()
2956 .map(|p| crate::discover::EntryPoint {
2957 path: p.clone(),
2958 source: crate::discover::EntryPointSource::PackageJsonMain,
2959 })
2960 .collect();
2961 fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
2962 }
2963
2964 fn make_module_info(
2966 file_id: u32,
2967 line_count: usize,
2968 functions: Vec<fallow_types::extract::FunctionComplexity>,
2969 ) -> crate::source::ModuleInfo {
2970 crate::source::ModuleInfo {
2971 line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
2972 complexity: functions,
2973 ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
2974 }
2975 }
2976
2977 fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
2978 FileHealthScore {
2979 path: std::path::PathBuf::from(path),
2980 fan_in: 0,
2981 fan_out: 0,
2982 dead_code_ratio: 0.0,
2983 complexity_density: 0.0,
2984 maintainability_index,
2985 total_cyclomatic: 0,
2986 total_cognitive: 0,
2987 function_count: 1,
2988 lines: 1,
2989 crap_max,
2990 crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
2991 crap_exempted: 0,
2992 crap_effective_threshold: None,
2993 }
2994 }
2995
2996 fn crap_concern_at_default(crap_max: f64) -> f64 {
2997 file_score_crap_concern(
2998 &make_file_score("/src/concern.ts", 100.0, crap_max),
2999 CRAP_THRESHOLD,
3000 )
3001 }
3002
3003 #[test]
3004 fn file_score_crap_concern_tracks_crap_risk_bands() {
3005 assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
3006 assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
3007 assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3008 assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
3009 assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
3010 }
3011
3012 #[test]
3013 fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
3014 let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
3017 at_edge.crap_above_threshold = 1;
3018 at_edge.crap_effective_threshold = Some(500.0);
3019 assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
3020
3021 let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
3022 at_ceiling.crap_above_threshold = 1;
3023 at_ceiling.crap_effective_threshold = Some(500.0);
3024 assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3025 }
3026
3027 #[test]
3028 fn file_score_crap_concern_zeroes_fully_exempt_file() {
3029 let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
3034 exempt.crap_above_threshold = 0;
3035 exempt.crap_exempted = 2;
3036 exempt.crap_effective_threshold = Some(500.0);
3037 assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
3038 assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
3039 assert_eq!(
3040 file_score_concern_axis(&exempt, CRAP_THRESHOLD),
3041 FileScoreConcern::Structural
3042 );
3043 }
3044
3045 #[test]
3046 fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
3047 let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
3048 score.crap_above_threshold = 0;
3049 score.crap_exempted = 2;
3050 assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
3051 assert!(file_score_fully_crap_exempt(&score, 0.0));
3052 assert_eq!(
3053 file_score_concern_axis(&score, 0.0),
3054 FileScoreConcern::Structural
3055 );
3056 }
3057
3058 #[test]
3059 fn file_score_partial_exemption_keeps_risk_axis() {
3060 let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
3063 mixed.crap_above_threshold = 1;
3064 mixed.crap_exempted = 1;
3065 mixed.crap_effective_threshold = Some(30.0);
3066 assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
3067 assert_eq!(
3068 file_score_concern_axis(&mixed, CRAP_THRESHOLD),
3069 FileScoreConcern::Risk
3070 );
3071 }
3072
3073 #[test]
3074 fn file_score_concern_axis_labels_dominant_signal() {
3075 let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
3076 assert_eq!(
3077 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
3078 FileScoreConcern::Risk
3079 );
3080 assert_eq!(
3081 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
3082 "risk"
3083 );
3084
3085 let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
3086 assert_eq!(
3087 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
3088 FileScoreConcern::Structural
3089 );
3090 assert_eq!(
3091 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
3092 "structure"
3093 );
3094
3095 let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
3096 assert_eq!(
3097 file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
3098 FileScoreConcern::Structural
3099 );
3100 }
3101
3102 #[test]
3103 fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
3104 let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
3105 let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
3106
3107 let mut scores = [low_mi_low_risk, higher_mi_high_risk];
3108 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3109
3110 assert_eq!(
3111 scores[0].path,
3112 std::path::Path::new("/src/higher-mi-high-risk.ts")
3113 );
3114 assert_eq!(
3115 scores[1].path,
3116 std::path::Path::new("/src/low-mi-low-risk.ts")
3117 );
3118 }
3119
3120 #[test]
3121 fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
3122 let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
3123 let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
3124
3125 let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
3126 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3127
3128 assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
3129 assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
3130 }
3131
3132 #[test]
3133 fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
3134 let mut scores = [
3135 make_file_score("/src/b.ts", 70.0, 1.0),
3136 make_file_score("/src/a.ts", 70.0, 1.0),
3137 make_file_score("/src/higher-crap.ts", 70.0, 2.0),
3138 make_file_score("/src/lower-concern.ts", 80.0, 1.0),
3139 ];
3140
3141 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3142
3143 let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
3144 assert_eq!(
3145 paths,
3146 vec![
3147 std::path::Path::new("/src/higher-crap.ts"),
3148 std::path::Path::new("/src/a.ts"),
3149 std::path::Path::new("/src/b.ts"),
3150 std::path::Path::new("/src/lower-concern.ts"),
3151 ]
3152 );
3153 }
3154
3155 #[test]
3156 fn compute_file_scores_empty_graph() {
3157 let files: Vec<crate::discover::DiscoveredFile> = vec![];
3158 let graph = build_test_graph(&files, &[], &[]);
3159 let modules: Vec<crate::source::ModuleInfo> = vec![];
3160 let file_paths = rustc_hash::FxHashMap::default();
3161
3162 let output = crate::results::DeadCodeAnalysisArtifacts {
3163 results: fallow_types::results::AnalysisResults::default(),
3164 timings: None,
3165 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3166 modules: None,
3167 files: None,
3168 script_used_packages: rustc_hash::FxHashSet::default(),
3169 file_hashes: rustc_hash::FxHashMap::default(),
3170 };
3171
3172 let result = compute_file_scores_default(
3173 &modules,
3174 &file_paths,
3175 None,
3176 output,
3177 None,
3178 std::path::Path::new("/project"),
3179 )
3180 .unwrap();
3181 assert!(result.scores.is_empty());
3182 assert!(result.circular_files.is_empty());
3183 assert!(result.top_complex_fns.is_empty());
3184 assert!(result.entry_points.is_empty());
3185 assert_eq!(result.analysis_counts.total_exports, 0);
3186 assert_eq!(result.analysis_counts.dead_files, 0);
3187 }
3188
3189 #[test]
3190 fn compute_file_scores_no_graph_returns_error() {
3191 let modules: Vec<crate::source::ModuleInfo> = vec![];
3192 let file_paths = rustc_hash::FxHashMap::default();
3193
3194 let output = crate::results::DeadCodeAnalysisArtifacts {
3195 results: fallow_types::results::AnalysisResults::default(),
3196 timings: None,
3197 graph: None,
3198 modules: None,
3199 files: None,
3200 script_used_packages: rustc_hash::FxHashSet::default(),
3201 file_hashes: rustc_hash::FxHashMap::default(),
3202 };
3203
3204 let result = compute_file_scores_default(
3205 &modules,
3206 &file_paths,
3207 None,
3208 output,
3209 None,
3210 std::path::Path::new("/project"),
3211 );
3212 assert!(result.is_err());
3213 match result {
3214 Err(msg) => assert_eq!(msg, "graph not available"),
3215 Ok(_) => panic!("expected error"),
3216 }
3217 }
3218
3219 #[test]
3220 fn compute_file_scores_single_file_with_function() {
3221 let path_a = std::path::PathBuf::from("/src/a.ts");
3222 let files = vec![crate::discover::DiscoveredFile {
3223 id: crate::discover::FileId(0),
3224 path: path_a.clone(),
3225 size_bytes: 100,
3226 }];
3227
3228 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3229 file_id: crate::discover::FileId(0),
3230 path: path_a.clone(),
3231 exports: vec![fallow_types::extract::ExportInfo {
3232 name: crate::source::ExportName::Named("foo".into()),
3233 local_name: None,
3234 is_type_only: false,
3235 visibility: crate::source::VisibilityTag::None,
3236 expected_unused_reason: None,
3237 span: oxc_span::Span::empty(0),
3238 members: vec![],
3239 is_side_effect_used: false,
3240 super_class: None,
3241 }]
3242 .into(),
3243 ..Default::default()
3244 }];
3245
3246 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
3247
3248 let modules = vec![make_module_info(
3249 0,
3250 10,
3251 vec![fallow_types::extract::FunctionComplexity {
3252 name: "foo".into(),
3253 line: 1,
3254 col: 0,
3255 cyclomatic: 5,
3256 cognitive: 3,
3257 line_count: 10,
3258 param_count: 0,
3259 react_hook_count: 0,
3260 react_jsx_max_depth: 0,
3261 react_prop_count: 0,
3262 source_hash: None,
3263 contributions: Vec::new(),
3264 }],
3265 )];
3266
3267 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3268 rustc_hash::FxHashMap::default();
3269 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3270
3271 let output = crate::results::DeadCodeAnalysisArtifacts {
3272 results: fallow_types::results::AnalysisResults::default(),
3273 timings: None,
3274 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3275 modules: None,
3276 files: None,
3277 script_used_packages: rustc_hash::FxHashSet::default(),
3278 file_hashes: rustc_hash::FxHashMap::default(),
3279 };
3280
3281 let result = compute_file_scores_default(
3282 &modules,
3283 &file_paths,
3284 None,
3285 output,
3286 None,
3287 std::path::Path::new("/project"),
3288 )
3289 .unwrap();
3290 assert_eq!(result.scores.len(), 1);
3291
3292 let score = &result.scores[0];
3293 assert_eq!(score.path, path_a);
3294 assert_eq!(score.total_cyclomatic, 5);
3295 assert_eq!(score.total_cognitive, 3);
3296 assert_eq!(score.function_count, 1);
3297 assert_eq!(score.lines, 10);
3298 assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
3299 assert!(score.dead_code_ratio.abs() < f64::EPSILON);
3300 assert!(result.entry_points.contains(&path_a));
3301 }
3302
3303 #[test]
3304 fn compute_file_scores_excludes_barrel_files() {
3305 let path_a = std::path::PathBuf::from("/src/index.ts");
3306 let files = vec![crate::discover::DiscoveredFile {
3307 id: crate::discover::FileId(0),
3308 path: path_a.clone(),
3309 size_bytes: 50,
3310 }];
3311
3312 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3313 file_id: crate::discover::FileId(0),
3314 path: path_a.clone(),
3315 ..Default::default()
3316 }];
3317
3318 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
3319
3320 let modules = vec![make_module_info(0, 5, vec![])];
3321
3322 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3323 rustc_hash::FxHashMap::default();
3324 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3325
3326 let output = crate::results::DeadCodeAnalysisArtifacts {
3327 results: fallow_types::results::AnalysisResults::default(),
3328 timings: None,
3329 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3330 modules: None,
3331 files: None,
3332 script_used_packages: rustc_hash::FxHashSet::default(),
3333 file_hashes: rustc_hash::FxHashMap::default(),
3334 };
3335
3336 let result = compute_file_scores_default(
3337 &modules,
3338 &file_paths,
3339 None,
3340 output,
3341 None,
3342 std::path::Path::new("/project"),
3343 )
3344 .unwrap();
3345 assert!(result.scores.is_empty());
3346 }
3347
3348 #[test]
3349 fn compute_file_scores_changed_since_filter() {
3350 let path_a = std::path::PathBuf::from("/src/a.ts");
3351 let path_b = std::path::PathBuf::from("/src/b.ts");
3352 let files = vec![
3353 crate::discover::DiscoveredFile {
3354 id: crate::discover::FileId(0),
3355 path: path_a.clone(),
3356 size_bytes: 100,
3357 },
3358 crate::discover::DiscoveredFile {
3359 id: crate::discover::FileId(1),
3360 path: path_b.clone(),
3361 size_bytes: 100,
3362 },
3363 ];
3364
3365 let resolved_modules = vec![
3366 fallow_graph::resolve::ResolvedModule {
3367 file_id: crate::discover::FileId(0),
3368 path: path_a,
3369 ..Default::default()
3370 },
3371 fallow_graph::resolve::ResolvedModule {
3372 file_id: crate::discover::FileId(1),
3373 path: path_b.clone(),
3374 ..Default::default()
3375 },
3376 ];
3377
3378 let graph = build_test_graph(&files, &[], &resolved_modules);
3379
3380 let modules = vec![
3381 make_module_info(
3382 0,
3383 10,
3384 vec![fallow_types::extract::FunctionComplexity {
3385 name: "fn_a".into(),
3386 line: 1,
3387 col: 0,
3388 cyclomatic: 2,
3389 cognitive: 1,
3390 line_count: 10,
3391 param_count: 0,
3392 react_hook_count: 0,
3393 react_jsx_max_depth: 0,
3394 react_prop_count: 0,
3395 source_hash: None,
3396 contributions: Vec::new(),
3397 }],
3398 ),
3399 make_module_info(
3400 1,
3401 10,
3402 vec![fallow_types::extract::FunctionComplexity {
3403 name: "fn_b".into(),
3404 line: 1,
3405 col: 0,
3406 cyclomatic: 3,
3407 cognitive: 2,
3408 line_count: 10,
3409 param_count: 0,
3410 react_hook_count: 0,
3411 react_jsx_max_depth: 0,
3412 react_prop_count: 0,
3413 source_hash: None,
3414 contributions: Vec::new(),
3415 }],
3416 ),
3417 ];
3418
3419 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3420 rustc_hash::FxHashMap::default();
3421 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3422 file_paths.insert(crate::discover::FileId(1), &files[1].path);
3423
3424 let path_b_check = std::path::PathBuf::from("/src/b.ts");
3425 let mut changed = rustc_hash::FxHashSet::default();
3426 changed.insert(path_b);
3427
3428 let output = crate::results::DeadCodeAnalysisArtifacts {
3429 results: fallow_types::results::AnalysisResults::default(),
3430 timings: None,
3431 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3432 modules: None,
3433 files: None,
3434 script_used_packages: rustc_hash::FxHashSet::default(),
3435 file_hashes: rustc_hash::FxHashMap::default(),
3436 };
3437
3438 let result = compute_file_scores_default(
3439 &modules,
3440 &file_paths,
3441 Some(&changed),
3442 output,
3443 None,
3444 std::path::Path::new("/project"),
3445 )
3446 .unwrap();
3447 assert_eq!(result.scores.len(), 1);
3448 assert_eq!(result.scores[0].path, path_b_check);
3449 }
3450
3451 #[test]
3452 fn compute_file_scores_sorted_by_triage_concern() {
3453 let path_a = std::path::PathBuf::from("/src/a.ts");
3454 let path_b = std::path::PathBuf::from("/src/b.ts");
3455 let files = vec![
3456 crate::discover::DiscoveredFile {
3457 id: crate::discover::FileId(0),
3458 path: path_a.clone(),
3459 size_bytes: 100,
3460 },
3461 crate::discover::DiscoveredFile {
3462 id: crate::discover::FileId(1),
3463 path: path_b.clone(),
3464 size_bytes: 100,
3465 },
3466 ];
3467
3468 let resolved_modules = vec![
3469 fallow_graph::resolve::ResolvedModule {
3470 file_id: crate::discover::FileId(0),
3471 path: path_a.clone(),
3472 ..Default::default()
3473 },
3474 fallow_graph::resolve::ResolvedModule {
3475 file_id: crate::discover::FileId(1),
3476 path: path_b,
3477 ..Default::default()
3478 },
3479 ];
3480
3481 let graph = build_test_graph(&files, &[], &resolved_modules);
3482
3483 let modules = vec![
3484 make_module_info(
3485 0,
3486 10,
3487 vec![fallow_types::extract::FunctionComplexity {
3488 name: "complex_fn".into(),
3489 line: 1,
3490 col: 0,
3491 cyclomatic: 30,
3492 cognitive: 20,
3493 line_count: 10,
3494 param_count: 0,
3495 react_hook_count: 0,
3496 react_jsx_max_depth: 0,
3497 react_prop_count: 0,
3498 source_hash: None,
3499 contributions: Vec::new(),
3500 }],
3501 ),
3502 make_module_info(
3503 1,
3504 100,
3505 vec![fallow_types::extract::FunctionComplexity {
3506 name: "simple_fn".into(),
3507 line: 1,
3508 col: 0,
3509 cyclomatic: 1,
3510 cognitive: 0,
3511 line_count: 100,
3512 param_count: 0,
3513 react_hook_count: 0,
3514 react_jsx_max_depth: 0,
3515 react_prop_count: 0,
3516 source_hash: None,
3517 contributions: Vec::new(),
3518 }],
3519 ),
3520 ];
3521
3522 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3523 rustc_hash::FxHashMap::default();
3524 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3525 file_paths.insert(crate::discover::FileId(1), &files[1].path);
3526
3527 let output = crate::results::DeadCodeAnalysisArtifacts {
3528 results: fallow_types::results::AnalysisResults::default(),
3529 timings: None,
3530 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3531 modules: None,
3532 files: None,
3533 script_used_packages: rustc_hash::FxHashSet::default(),
3534 file_hashes: rustc_hash::FxHashMap::default(),
3535 };
3536
3537 let result = compute_file_scores_default(
3538 &modules,
3539 &file_paths,
3540 None,
3541 output,
3542 None,
3543 std::path::Path::new("/project"),
3544 )
3545 .unwrap();
3546 assert_eq!(result.scores.len(), 2);
3547 assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
3548 assert_eq!(result.scores[0].path, path_a);
3549 }
3550
3551 #[test]
3552 fn compute_file_scores_with_unused_file_populates_evidence() {
3553 let path_a = std::path::PathBuf::from("/src/unused.ts");
3554 let files = vec![crate::discover::DiscoveredFile {
3555 id: crate::discover::FileId(0),
3556 path: path_a.clone(),
3557 size_bytes: 100,
3558 }];
3559
3560 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3561 file_id: crate::discover::FileId(0),
3562 path: path_a.clone(),
3563 exports: vec![fallow_types::extract::ExportInfo {
3564 name: crate::source::ExportName::Named("orphan".into()),
3565 local_name: None,
3566 is_type_only: false,
3567 visibility: crate::source::VisibilityTag::None,
3568 expected_unused_reason: None,
3569 span: oxc_span::Span::empty(0),
3570 members: vec![],
3571 is_side_effect_used: false,
3572 super_class: None,
3573 }]
3574 .into(),
3575 ..Default::default()
3576 }];
3577
3578 let graph = build_test_graph(&files, &[], &resolved_modules);
3579
3580 let modules = vec![make_module_info(
3581 0,
3582 10,
3583 vec![fallow_types::extract::FunctionComplexity {
3584 name: "orphan".into(),
3585 line: 1,
3586 col: 0,
3587 cyclomatic: 1,
3588 cognitive: 0,
3589 line_count: 10,
3590 param_count: 0,
3591 react_hook_count: 0,
3592 react_jsx_max_depth: 0,
3593 react_prop_count: 0,
3594 source_hash: None,
3595 contributions: Vec::new(),
3596 }],
3597 )];
3598
3599 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3600 rustc_hash::FxHashMap::default();
3601 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3602
3603 let mut results = fallow_types::results::AnalysisResults::default();
3604 results.unused_files.push(
3605 fallow_types::output_dead_code::UnusedFileFinding::with_actions(
3606 fallow_types::results::UnusedFile {
3607 path: path_a.clone(),
3608 },
3609 ),
3610 );
3611
3612 let output = crate::results::DeadCodeAnalysisArtifacts {
3613 results,
3614 timings: None,
3615 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3616 modules: None,
3617 files: None,
3618 script_used_packages: rustc_hash::FxHashSet::default(),
3619 file_hashes: rustc_hash::FxHashMap::default(),
3620 };
3621
3622 let result = compute_file_scores_default(
3623 &modules,
3624 &file_paths,
3625 None,
3626 output,
3627 None,
3628 std::path::Path::new("/project"),
3629 )
3630 .unwrap();
3631 assert_eq!(result.scores.len(), 1);
3632 assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
3633 assert!(result.unused_export_names.contains_key(&path_a));
3634 let names = &result.unused_export_names[&path_a];
3635 assert_eq!(names, &["orphan"]);
3636 assert_eq!(result.analysis_counts.dead_files, 1);
3637 }
3638
3639 #[test]
3640 #[expect(
3641 clippy::too_many_lines,
3642 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3643 )]
3644 fn compute_file_scores_tracks_top_complex_functions() {
3645 let path_a = std::path::PathBuf::from("/src/complex.ts");
3646 let files = vec![crate::discover::DiscoveredFile {
3647 id: crate::discover::FileId(0),
3648 path: path_a.clone(),
3649 size_bytes: 500,
3650 }];
3651
3652 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3653 file_id: crate::discover::FileId(0),
3654 path: path_a.clone(),
3655 ..Default::default()
3656 }];
3657
3658 let graph = build_test_graph(&files, &[], &resolved_modules);
3659
3660 let modules = vec![make_module_info(
3661 0,
3662 50,
3663 vec![
3664 fallow_types::extract::FunctionComplexity {
3665 name: "high".into(),
3666 line: 1,
3667 col: 0,
3668 cyclomatic: 10,
3669 cognitive: 20,
3670 line_count: 10,
3671 param_count: 0,
3672 react_hook_count: 0,
3673 react_jsx_max_depth: 0,
3674 react_prop_count: 0,
3675 source_hash: None,
3676 contributions: Vec::new(),
3677 },
3678 fallow_types::extract::FunctionComplexity {
3679 name: "medium".into(),
3680 line: 11,
3681 col: 0,
3682 cyclomatic: 5,
3683 cognitive: 10,
3684 line_count: 10,
3685 param_count: 0,
3686 react_hook_count: 0,
3687 react_jsx_max_depth: 0,
3688 react_prop_count: 0,
3689 source_hash: None,
3690 contributions: Vec::new(),
3691 },
3692 fallow_types::extract::FunctionComplexity {
3693 name: "low".into(),
3694 line: 21,
3695 col: 0,
3696 cyclomatic: 2,
3697 cognitive: 5,
3698 line_count: 10,
3699 param_count: 0,
3700 react_hook_count: 0,
3701 react_jsx_max_depth: 0,
3702 react_prop_count: 0,
3703 source_hash: None,
3704 contributions: Vec::new(),
3705 },
3706 fallow_types::extract::FunctionComplexity {
3707 name: "trivial".into(),
3708 line: 31,
3709 col: 0,
3710 cyclomatic: 1,
3711 cognitive: 1,
3712 line_count: 10,
3713 param_count: 0,
3714 react_hook_count: 0,
3715 react_jsx_max_depth: 0,
3716 react_prop_count: 0,
3717 source_hash: None,
3718 contributions: Vec::new(),
3719 },
3720 ],
3721 )];
3722
3723 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3724 rustc_hash::FxHashMap::default();
3725 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3726
3727 let output = crate::results::DeadCodeAnalysisArtifacts {
3728 results: fallow_types::results::AnalysisResults::default(),
3729 timings: None,
3730 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3731 modules: None,
3732 files: None,
3733 script_used_packages: rustc_hash::FxHashSet::default(),
3734 file_hashes: rustc_hash::FxHashMap::default(),
3735 };
3736
3737 let result = compute_file_scores_default(
3738 &modules,
3739 &file_paths,
3740 None,
3741 output,
3742 None,
3743 std::path::Path::new("/project"),
3744 )
3745 .unwrap();
3746 assert!(result.top_complex_fns.contains_key(&path_a));
3747 let top = &result.top_complex_fns[&path_a];
3748 assert_eq!(top.len(), 3);
3749 assert_eq!(top[0].0, "high");
3750 assert_eq!(top[0].2, 20);
3751 assert_eq!(top[1].0, "medium");
3752 assert_eq!(top[1].2, 10);
3753 assert_eq!(top[2].0, "low");
3754 assert_eq!(top[2].2, 5);
3755 }
3756
3757 #[test]
3758 #[expect(
3759 clippy::too_many_lines,
3760 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3761 )]
3762 fn compute_file_scores_with_circular_deps() {
3763 let path_a = std::path::PathBuf::from("/src/a.ts");
3764 let path_b = std::path::PathBuf::from("/src/b.ts");
3765 let files = vec![
3766 crate::discover::DiscoveredFile {
3767 id: crate::discover::FileId(0),
3768 path: path_a.clone(),
3769 size_bytes: 100,
3770 },
3771 crate::discover::DiscoveredFile {
3772 id: crate::discover::FileId(1),
3773 path: path_b.clone(),
3774 size_bytes: 100,
3775 },
3776 ];
3777
3778 let resolved_modules = vec![
3779 fallow_graph::resolve::ResolvedModule {
3780 file_id: crate::discover::FileId(0),
3781 path: path_a.clone(),
3782 ..Default::default()
3783 },
3784 fallow_graph::resolve::ResolvedModule {
3785 file_id: crate::discover::FileId(1),
3786 path: path_b.clone(),
3787 ..Default::default()
3788 },
3789 ];
3790
3791 let graph = build_test_graph(&files, &[], &resolved_modules);
3792
3793 let modules = vec![
3794 make_module_info(
3795 0,
3796 10,
3797 vec![fallow_types::extract::FunctionComplexity {
3798 name: "fn_a".into(),
3799 line: 1,
3800 col: 0,
3801 cyclomatic: 2,
3802 cognitive: 1,
3803 line_count: 10,
3804 param_count: 0,
3805 react_hook_count: 0,
3806 react_jsx_max_depth: 0,
3807 react_prop_count: 0,
3808 source_hash: None,
3809 contributions: Vec::new(),
3810 }],
3811 ),
3812 make_module_info(
3813 1,
3814 10,
3815 vec![fallow_types::extract::FunctionComplexity {
3816 name: "fn_b".into(),
3817 line: 1,
3818 col: 0,
3819 cyclomatic: 3,
3820 cognitive: 2,
3821 line_count: 10,
3822 param_count: 0,
3823 react_hook_count: 0,
3824 react_jsx_max_depth: 0,
3825 react_prop_count: 0,
3826 source_hash: None,
3827 contributions: Vec::new(),
3828 }],
3829 ),
3830 ];
3831
3832 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3833 rustc_hash::FxHashMap::default();
3834 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3835 file_paths.insert(crate::discover::FileId(1), &files[1].path);
3836
3837 let mut results = fallow_types::results::AnalysisResults::default();
3838 results.circular_dependencies.push(
3839 fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
3840 fallow_types::results::CircularDependency {
3841 files: vec![path_a.clone(), path_b.clone()],
3842 length: 2,
3843 line: 1,
3844 col: 0,
3845 edges: Vec::new(),
3846 is_cross_package: false,
3847 },
3848 ),
3849 );
3850
3851 let output = crate::results::DeadCodeAnalysisArtifacts {
3852 results,
3853 timings: None,
3854 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3855 modules: None,
3856 files: None,
3857 script_used_packages: rustc_hash::FxHashSet::default(),
3858 file_hashes: rustc_hash::FxHashMap::default(),
3859 };
3860
3861 let result = compute_file_scores_default(
3862 &modules,
3863 &file_paths,
3864 None,
3865 output,
3866 None,
3867 std::path::Path::new("/project"),
3868 )
3869 .unwrap();
3870 assert!(result.circular_files.contains(&path_a));
3871 assert!(result.circular_files.contains(&path_b));
3872 assert!(result.cycle_members.contains_key(&path_a));
3873 assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
3874 assert!(result.cycle_members.contains_key(&path_b));
3875 assert_eq!(result.cycle_members[&path_b], vec![path_a]);
3876 assert_eq!(result.analysis_counts.circular_deps, 1);
3877 }
3878
3879 #[test]
3880 #[expect(
3881 clippy::too_many_lines,
3882 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3883 )]
3884 fn compute_file_scores_analysis_counts_unused_exports_and_types() {
3885 let path_a = std::path::PathBuf::from("/src/a.ts");
3886 let files = vec![crate::discover::DiscoveredFile {
3887 id: crate::discover::FileId(0),
3888 path: path_a.clone(),
3889 size_bytes: 100,
3890 }];
3891
3892 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
3893 file_id: crate::discover::FileId(0),
3894 path: path_a.clone(),
3895 exports: vec![
3896 fallow_types::extract::ExportInfo {
3897 name: crate::source::ExportName::Named("foo".into()),
3898 local_name: None,
3899 is_type_only: false,
3900 visibility: crate::source::VisibilityTag::None,
3901 expected_unused_reason: None,
3902 span: oxc_span::Span::empty(0),
3903 members: vec![],
3904 is_side_effect_used: false,
3905 super_class: None,
3906 },
3907 fallow_types::extract::ExportInfo {
3908 name: crate::source::ExportName::Named("bar".into()),
3909 local_name: None,
3910 is_type_only: false,
3911 visibility: crate::source::VisibilityTag::None,
3912 expected_unused_reason: None,
3913 span: oxc_span::Span::empty(0),
3914 members: vec![],
3915 is_side_effect_used: false,
3916 super_class: None,
3917 },
3918 ]
3919 .into(),
3920 ..Default::default()
3921 }];
3922
3923 let graph = build_test_graph(&files, &[], &resolved_modules);
3924
3925 let mut module = make_module_info(
3926 0,
3927 10,
3928 vec![fallow_types::extract::FunctionComplexity {
3929 name: "fn_a".into(),
3930 line: 1,
3931 col: 0,
3932 cyclomatic: 1,
3933 cognitive: 0,
3934 line_count: 10,
3935 param_count: 0,
3936 react_hook_count: 0,
3937 react_jsx_max_depth: 0,
3938 react_prop_count: 0,
3939 source_hash: None,
3940 contributions: Vec::new(),
3941 }],
3942 );
3943 module.exports = vec![
3944 fallow_types::extract::ExportInfo {
3945 name: crate::source::ExportName::Named("foo".into()),
3946 local_name: None,
3947 is_type_only: false,
3948 visibility: crate::source::VisibilityTag::None,
3949 expected_unused_reason: None,
3950 span: oxc_span::Span::empty(0),
3951 members: vec![],
3952 is_side_effect_used: false,
3953 super_class: None,
3954 },
3955 fallow_types::extract::ExportInfo {
3956 name: crate::source::ExportName::Named("bar".into()),
3957 local_name: None,
3958 is_type_only: false,
3959 visibility: crate::source::VisibilityTag::None,
3960 expected_unused_reason: None,
3961 span: oxc_span::Span::empty(0),
3962 members: vec![],
3963 is_side_effect_used: false,
3964 super_class: None,
3965 },
3966 ]
3967 .into();
3968 let modules = vec![module];
3969
3970 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
3971 rustc_hash::FxHashMap::default();
3972 file_paths.insert(crate::discover::FileId(0), &files[0].path);
3973
3974 let mut results = fallow_types::results::AnalysisResults::default();
3975 results.unused_exports.push(
3976 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
3977 fallow_types::results::UnusedExport {
3978 path: path_a.clone(),
3979 export_name: "foo".into(),
3980 is_type_only: false,
3981 line: 1,
3982 col: 0,
3983 span_start: 0,
3984 is_re_export: false,
3985 },
3986 ),
3987 );
3988 results.unused_types.push(
3989 fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
3990 fallow_types::results::UnusedExport {
3991 path: path_a,
3992 export_name: "MyType".into(),
3993 is_type_only: true,
3994 line: 5,
3995 col: 0,
3996 span_start: 40,
3997 is_re_export: false,
3998 },
3999 ),
4000 );
4001 results.unused_dependencies.push(
4002 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
4003 fallow_types::results::UnusedDependency {
4004 package_name: "lodash".into(),
4005 location: fallow_types::results::DependencyLocation::Dependencies,
4006 path: std::path::PathBuf::from("/package.json"),
4007 line: 1,
4008 used_in_workspaces: Vec::new(),
4009 },
4010 ),
4011 );
4012
4013 let output = crate::results::DeadCodeAnalysisArtifacts {
4014 results,
4015 timings: None,
4016 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4017 modules: None,
4018 files: None,
4019 script_used_packages: rustc_hash::FxHashSet::default(),
4020 file_hashes: rustc_hash::FxHashMap::default(),
4021 };
4022
4023 let result = compute_file_scores_default(
4024 &modules,
4025 &file_paths,
4026 None,
4027 output,
4028 None,
4029 std::path::Path::new("/project"),
4030 )
4031 .unwrap();
4032 assert_eq!(result.analysis_counts.total_exports, 2);
4033 assert_eq!(result.analysis_counts.dead_exports, 2);
4034 assert_eq!(result.analysis_counts.unused_deps, 1);
4035 }
4036
4037 #[test]
4039 #[expect(
4040 clippy::too_many_lines,
4041 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4042 )]
4043 fn total_exports_counts_graph_modules_not_extraction_modules() {
4044 let path_a = std::path::PathBuf::from("/src/a.ts");
4045 let files = vec![crate::discover::DiscoveredFile {
4046 id: crate::discover::FileId(0),
4047 path: path_a.clone(),
4048 size_bytes: 100,
4049 }];
4050
4051 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4052 file_id: crate::discover::FileId(0),
4053 path: path_a.clone(),
4054 exports: vec![
4055 fallow_types::extract::ExportInfo {
4056 name: crate::source::ExportName::Named("foo".into()),
4057 local_name: None,
4058 is_type_only: false,
4059 visibility: crate::source::VisibilityTag::None,
4060 expected_unused_reason: None,
4061 span: oxc_span::Span::empty(0),
4062 members: vec![],
4063 is_side_effect_used: false,
4064 super_class: None,
4065 },
4066 fallow_types::extract::ExportInfo {
4067 name: crate::source::ExportName::Named("bar".into()),
4068 local_name: None,
4069 is_type_only: false,
4070 visibility: crate::source::VisibilityTag::None,
4071 expected_unused_reason: None,
4072 span: oxc_span::Span::empty(0),
4073 members: vec![],
4074 is_side_effect_used: false,
4075 super_class: None,
4076 },
4077 fallow_types::extract::ExportInfo {
4078 name: crate::source::ExportName::Named("baz".into()),
4079 local_name: None,
4080 is_type_only: false,
4081 visibility: crate::source::VisibilityTag::None,
4082 expected_unused_reason: None,
4083 span: oxc_span::Span::new(0, 0),
4084 members: vec![],
4085 is_side_effect_used: false,
4086 super_class: None,
4087 },
4088 ]
4089 .into(),
4090 ..Default::default()
4091 }];
4092
4093 let graph = build_test_graph(&files, &[], &resolved_modules);
4094
4095 let mut module = make_module_info(
4096 0,
4097 10,
4098 vec![fallow_types::extract::FunctionComplexity {
4099 name: "fn_a".into(),
4100 line: 1,
4101 col: 0,
4102 cyclomatic: 1,
4103 cognitive: 0,
4104 line_count: 10,
4105 param_count: 0,
4106 react_hook_count: 0,
4107 react_jsx_max_depth: 0,
4108 react_prop_count: 0,
4109 source_hash: None,
4110 contributions: Vec::new(),
4111 }],
4112 );
4113 module.exports = vec![
4114 fallow_types::extract::ExportInfo {
4115 name: crate::source::ExportName::Named("foo".into()),
4116 local_name: None,
4117 is_type_only: false,
4118 visibility: crate::source::VisibilityTag::None,
4119 expected_unused_reason: None,
4120 span: oxc_span::Span::empty(0),
4121 members: vec![],
4122 is_side_effect_used: false,
4123 super_class: None,
4124 },
4125 fallow_types::extract::ExportInfo {
4126 name: crate::source::ExportName::Named("bar".into()),
4127 local_name: None,
4128 is_type_only: false,
4129 visibility: crate::source::VisibilityTag::None,
4130 expected_unused_reason: None,
4131 span: oxc_span::Span::empty(0),
4132 members: vec![],
4133 is_side_effect_used: false,
4134 super_class: None,
4135 },
4136 ]
4137 .into();
4138 let modules = vec![module];
4139
4140 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4141 rustc_hash::FxHashMap::default();
4142 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4143
4144 let mut results = fallow_types::results::AnalysisResults::default();
4145 for name in ["foo", "bar", "baz"] {
4146 results.unused_exports.push(
4147 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
4148 fallow_types::results::UnusedExport {
4149 path: path_a.clone(),
4150 export_name: name.into(),
4151 is_type_only: false,
4152 line: 1,
4153 col: 0,
4154 span_start: 0,
4155 is_re_export: name == "baz",
4156 },
4157 ),
4158 );
4159 }
4160
4161 let output = crate::results::DeadCodeAnalysisArtifacts {
4162 results,
4163 timings: None,
4164 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4165 modules: None,
4166 files: None,
4167 script_used_packages: rustc_hash::FxHashSet::default(),
4168 file_hashes: rustc_hash::FxHashMap::default(),
4169 };
4170
4171 let result = compute_file_scores_default(
4172 &modules,
4173 &file_paths,
4174 None,
4175 output,
4176 None,
4177 std::path::Path::new("/project"),
4178 )
4179 .unwrap();
4180 assert_eq!(result.analysis_counts.total_exports, 3);
4181 assert_eq!(result.analysis_counts.dead_exports, 3);
4182 }
4183
4184 #[test]
4185 fn compute_file_scores_module_not_in_file_paths_skipped() {
4186 let path_a = std::path::PathBuf::from("/src/a.ts");
4187 let files = vec![crate::discover::DiscoveredFile {
4188 id: crate::discover::FileId(0),
4189 path: path_a.clone(),
4190 size_bytes: 100,
4191 }];
4192
4193 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4194 file_id: crate::discover::FileId(0),
4195 path: path_a,
4196 ..Default::default()
4197 }];
4198
4199 let graph = build_test_graph(&files, &[], &resolved_modules);
4200
4201 let modules = vec![make_module_info(
4202 0,
4203 10,
4204 vec![fallow_types::extract::FunctionComplexity {
4205 name: "fn_a".into(),
4206 line: 1,
4207 col: 0,
4208 cyclomatic: 2,
4209 cognitive: 1,
4210 line_count: 10,
4211 param_count: 0,
4212 react_hook_count: 0,
4213 react_jsx_max_depth: 0,
4214 react_prop_count: 0,
4215 source_hash: None,
4216 contributions: Vec::new(),
4217 }],
4218 )];
4219
4220 let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4221 rustc_hash::FxHashMap::default();
4222
4223 let output = crate::results::DeadCodeAnalysisArtifacts {
4224 results: fallow_types::results::AnalysisResults::default(),
4225 timings: None,
4226 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4227 modules: None,
4228 files: None,
4229 script_used_packages: rustc_hash::FxHashSet::default(),
4230 file_hashes: rustc_hash::FxHashMap::default(),
4231 };
4232
4233 let result = compute_file_scores_default(
4234 &modules,
4235 &file_paths,
4236 None,
4237 output,
4238 None,
4239 std::path::Path::new("/project"),
4240 )
4241 .unwrap();
4242 assert!(result.scores.is_empty());
4243 }
4244
4245 #[test]
4246 fn compute_file_scores_mi_rounded_to_one_decimal() {
4247 let path_a = std::path::PathBuf::from("/src/a.ts");
4248 let files = vec![crate::discover::DiscoveredFile {
4249 id: crate::discover::FileId(0),
4250 path: path_a.clone(),
4251 size_bytes: 100,
4252 }];
4253
4254 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4255 file_id: crate::discover::FileId(0),
4256 path: path_a.clone(),
4257 ..Default::default()
4258 }];
4259
4260 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4261
4262 let modules = vec![make_module_info(
4263 0,
4264 100,
4265 vec![fallow_types::extract::FunctionComplexity {
4266 name: "fn".into(),
4267 line: 1,
4268 col: 0,
4269 cyclomatic: 7,
4270 cognitive: 3,
4271 line_count: 100,
4272 param_count: 0,
4273 react_hook_count: 0,
4274 react_jsx_max_depth: 0,
4275 react_prop_count: 0,
4276 source_hash: None,
4277 contributions: Vec::new(),
4278 }],
4279 )];
4280
4281 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4282 rustc_hash::FxHashMap::default();
4283 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4284
4285 let output = crate::results::DeadCodeAnalysisArtifacts {
4286 results: fallow_types::results::AnalysisResults::default(),
4287 timings: None,
4288 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4289 modules: None,
4290 files: None,
4291 script_used_packages: rustc_hash::FxHashSet::default(),
4292 file_hashes: rustc_hash::FxHashMap::default(),
4293 };
4294
4295 let result = compute_file_scores_default(
4296 &modules,
4297 &file_paths,
4298 None,
4299 output,
4300 None,
4301 std::path::Path::new("/project"),
4302 )
4303 .unwrap();
4304 let mi = result.scores[0].maintainability_index;
4305 let rounded = (mi * 10.0).round() / 10.0;
4306 assert!((mi - rounded).abs() < f64::EPSILON);
4307 }
4308
4309 #[test]
4310 fn compute_file_scores_value_export_counts_tracked() {
4311 let path_a = std::path::PathBuf::from("/src/a.ts");
4312 let files = vec![crate::discover::DiscoveredFile {
4313 id: crate::discover::FileId(0),
4314 path: path_a.clone(),
4315 size_bytes: 100,
4316 }];
4317
4318 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4319 file_id: crate::discover::FileId(0),
4320 path: path_a.clone(),
4321 exports: vec![
4322 fallow_types::extract::ExportInfo {
4323 name: crate::source::ExportName::Named("a".into()),
4324 local_name: None,
4325 is_type_only: false,
4326 visibility: crate::source::VisibilityTag::None,
4327 expected_unused_reason: None,
4328 span: oxc_span::Span::empty(0),
4329 members: vec![],
4330 is_side_effect_used: false,
4331 super_class: None,
4332 },
4333 fallow_types::extract::ExportInfo {
4334 name: crate::source::ExportName::Named("b".into()),
4335 local_name: None,
4336 is_type_only: false,
4337 visibility: crate::source::VisibilityTag::None,
4338 expected_unused_reason: None,
4339 span: oxc_span::Span::empty(0),
4340 members: vec![],
4341 is_side_effect_used: false,
4342 super_class: None,
4343 },
4344 fallow_types::extract::ExportInfo {
4345 name: crate::source::ExportName::Named("T".into()),
4346 local_name: None,
4347 is_type_only: true,
4348 visibility: crate::source::VisibilityTag::None,
4349 expected_unused_reason: None,
4350 span: oxc_span::Span::empty(0),
4351 members: vec![],
4352 is_side_effect_used: false,
4353 super_class: None,
4354 },
4355 ]
4356 .into(),
4357 ..Default::default()
4358 }];
4359
4360 let graph = build_test_graph(&files, &[], &resolved_modules);
4361
4362 let modules = vec![make_module_info(
4363 0,
4364 10,
4365 vec![fallow_types::extract::FunctionComplexity {
4366 name: "fn_a".into(),
4367 line: 1,
4368 col: 0,
4369 cyclomatic: 2,
4370 cognitive: 1,
4371 line_count: 10,
4372 param_count: 0,
4373 react_hook_count: 0,
4374 react_jsx_max_depth: 0,
4375 react_prop_count: 0,
4376 source_hash: None,
4377 contributions: Vec::new(),
4378 }],
4379 )];
4380
4381 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4382 rustc_hash::FxHashMap::default();
4383 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4384
4385 let output = crate::results::DeadCodeAnalysisArtifacts {
4386 results: fallow_types::results::AnalysisResults::default(),
4387 timings: None,
4388 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4389 modules: None,
4390 files: None,
4391 script_used_packages: rustc_hash::FxHashSet::default(),
4392 file_hashes: rustc_hash::FxHashMap::default(),
4393 };
4394
4395 let result = compute_file_scores_default(
4396 &modules,
4397 &file_paths,
4398 None,
4399 output,
4400 None,
4401 std::path::Path::new("/project"),
4402 )
4403 .unwrap();
4404 assert_eq!(result.value_export_counts[&path_a], 2);
4405 }
4406
4407 #[test]
4408 fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
4409 let path_a = std::path::PathBuf::from("/src/simple.ts");
4410 let files = vec![crate::discover::DiscoveredFile {
4411 id: crate::discover::FileId(0),
4412 path: path_a.clone(),
4413 size_bytes: 100,
4414 }];
4415
4416 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4417 file_id: crate::discover::FileId(0),
4418 path: path_a.clone(),
4419 ..Default::default()
4420 }];
4421
4422 let graph = build_test_graph(&files, &[], &resolved_modules);
4423
4424 let modules = vec![make_module_info(
4425 0,
4426 10,
4427 vec![fallow_types::extract::FunctionComplexity {
4428 name: "trivial".into(),
4429 line: 1,
4430 col: 0,
4431 cyclomatic: 1,
4432 cognitive: 0,
4433 line_count: 10,
4434 param_count: 0,
4435 react_hook_count: 0,
4436 react_jsx_max_depth: 0,
4437 react_prop_count: 0,
4438 source_hash: None,
4439 contributions: Vec::new(),
4440 }],
4441 )];
4442
4443 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4444 rustc_hash::FxHashMap::default();
4445 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4446
4447 let output = crate::results::DeadCodeAnalysisArtifacts {
4448 results: fallow_types::results::AnalysisResults::default(),
4449 timings: None,
4450 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4451 modules: None,
4452 files: None,
4453 script_used_packages: rustc_hash::FxHashSet::default(),
4454 file_hashes: rustc_hash::FxHashMap::default(),
4455 };
4456
4457 let result = compute_file_scores_default(
4458 &modules,
4459 &file_paths,
4460 None,
4461 output,
4462 None,
4463 std::path::Path::new("/project"),
4464 )
4465 .unwrap();
4466 assert!(!result.top_complex_fns.contains_key(&path_a));
4467 }
4468
4469 fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
4470 fallow_types::extract::FunctionComplexity {
4471 name: "test_fn".into(),
4472 line: 1,
4473 col: 0,
4474 cyclomatic,
4475 cognitive: 0,
4476 line_count: 10,
4477 param_count: 0,
4478 react_hook_count: 0,
4479 react_jsx_max_depth: 0,
4480 react_prop_count: 0,
4481 source_hash: None,
4482 contributions: Vec::new(),
4483 }
4484 }
4485
4486 fn make_named_fn_complexity(
4487 name: &str,
4488 line: u32,
4489 cyclomatic: u16,
4490 ) -> fallow_types::extract::FunctionComplexity {
4491 fallow_types::extract::FunctionComplexity {
4492 name: name.into(),
4493 line,
4494 col: 0,
4495 cyclomatic,
4496 cognitive: 0,
4497 line_count: 10,
4498 param_count: 0,
4499 react_hook_count: 0,
4500 react_jsx_max_depth: 0,
4501 react_prop_count: 0,
4502 source_hash: None,
4503 contributions: Vec::new(),
4504 }
4505 }
4506
4507 fn crap_override_entry(
4508 files: &[&str],
4509 functions: &[&str],
4510 max_crap: Option<f64>,
4511 ) -> fallow_config::HealthThresholdOverride {
4512 fallow_config::HealthThresholdOverride {
4513 files: files.iter().map(ToString::to_string).collect(),
4514 functions: functions.iter().map(ToString::to_string).collect(),
4515 max_cyclomatic: None,
4516 max_cognitive: None,
4517 max_crap,
4518 max_unit_size: None,
4519 reason: Some("test override".into()),
4520 }
4521 }
4522
4523 fn estimated_signals_with(
4524 resolver: &ThresholdOverrideResolver,
4525 relative: &str,
4526 enforce_crap: bool,
4527 complexity: &[fallow_types::extract::FunctionComplexity],
4528 ) -> CrapThresholdSignals {
4529 let ceilings = CrapCeilingLookup::new(
4530 CrapScoreThresholds {
4531 resolver,
4532 enforce_crap,
4533 },
4534 std::path::Path::new(relative),
4535 );
4536 compute_crap_scores_estimated(
4537 complexity,
4538 &rustc_hash::FxHashSet::default(),
4539 false,
4540 fallow_output::CoverageSource::Estimated,
4541 &ceilings,
4542 )
4543 .signals
4544 }
4545
4546 #[test]
4547 fn crap_counting_exempts_functions_under_override_ceiling() {
4548 let resolver =
4551 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
4552 let fns = vec![
4553 make_named_fn_complexity("a", 1, 10),
4554 make_named_fn_complexity("b", 12, 10),
4555 ];
4556
4557 let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4558 assert_eq!(covered.above, 0);
4559 assert_eq!(covered.exempted, 2);
4560 assert_eq!(covered.min_ceiling, Some(500.0));
4561
4562 let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
4563 assert_eq!(elsewhere.above, 2);
4564 assert_eq!(elsewhere.exempted, 0);
4565 assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
4566 }
4567
4568 #[test]
4569 fn crap_counting_insufficient_override_keeps_count() {
4570 let resolver =
4571 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
4572 let fns = vec![
4573 make_named_fn_complexity("a", 1, 10),
4574 make_named_fn_complexity("b", 12, 10),
4575 ];
4576
4577 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4578 assert_eq!(signals.above, 2);
4579 assert_eq!(signals.exempted, 0);
4580 assert_eq!(signals.min_ceiling, Some(50.0));
4581 }
4582
4583 #[test]
4584 fn crap_counting_partial_function_override() {
4585 let resolver =
4588 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
4589 let fns = vec![
4590 make_named_fn_complexity("a", 1, 10),
4591 make_named_fn_complexity("b", 12, 10),
4592 ];
4593
4594 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
4595 assert_eq!(signals.above, 1);
4596 assert_eq!(signals.exempted, 1);
4597 assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
4598 }
4599
4600 #[test]
4601 fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
4602 let resolver = test_crap_resolver(0.0);
4605 let fns = vec![
4606 make_named_fn_complexity("a", 1, 10),
4607 make_named_fn_complexity("b", 12, 10),
4608 make_named_fn_complexity("tiny", 24, 1),
4609 ];
4610
4611 let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
4612 assert_eq!(signals.above, 0);
4613 assert_eq!(signals.exempted, 2);
4614 }
4615
4616 #[test]
4617 fn crap_counting_stricter_ceiling_never_counts_exempt() {
4618 let resolver = test_crap_resolver(10.0);
4621 let fns = vec![make_named_fn_complexity("a", 1, 4)]; let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
4624 assert_eq!(signals.above, 1);
4625 assert_eq!(signals.exempted, 0);
4626 }
4627
4628 #[test]
4629 fn crap_counting_uses_rounded_value_at_boundary() {
4630 let funcs = vec![make_fn_complexity(10)];
4635 let mut functions = rustc_hash::FxHashMap::default();
4636 functions.insert(("test_fn".to_string(), 1, 0), 41.56);
4637 let file_cov = test_istanbul_file_coverage(functions, false);
4638
4639 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4640 assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
4641 assert_eq!(result.signals.above, 1);
4642 assert_eq!(result.signals.exempted, 0);
4643 }
4644
4645 #[test]
4646 fn compute_file_scores_discloses_override_exemption_on_row() {
4647 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
4648 let files = vec![crate::discover::DiscoveredFile {
4649 id: crate::discover::FileId(0),
4650 path: path_a.clone(),
4651 size_bytes: 100,
4652 }];
4653 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4654 file_id: crate::discover::FileId(0),
4655 path: path_a.clone(),
4656 ..Default::default()
4657 }];
4658 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4659 let modules = vec![make_module_info(
4660 0,
4661 26,
4662 vec![
4663 make_named_fn_complexity("a", 1, 10),
4664 make_named_fn_complexity("b", 12, 10),
4665 ],
4666 )];
4667 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4668 rustc_hash::FxHashMap::default();
4669 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4670 let output = crate::results::DeadCodeAnalysisArtifacts {
4671 results: fallow_types::results::AnalysisResults::default(),
4672 timings: None,
4673 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4674 modules: None,
4675 files: None,
4676 script_used_packages: rustc_hash::FxHashSet::default(),
4677 file_hashes: rustc_hash::FxHashMap::default(),
4678 };
4679
4680 let resolver =
4681 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
4682 let result = compute_file_scores(
4683 FileScoreComputeInput {
4684 modules: &modules,
4685 file_paths: &file_paths,
4686 changed_files: None,
4687 istanbul_coverage: None,
4688 root: std::path::Path::new("/project"),
4689 crap_thresholds: CrapScoreThresholds {
4690 resolver: &resolver,
4691 enforce_crap: true,
4692 },
4693 },
4694 output,
4695 )
4696 .unwrap();
4697
4698 assert_eq!(result.scores.len(), 1);
4699 let score = &result.scores[0];
4700 assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
4701 assert_eq!(score.crap_above_threshold, 0);
4702 assert_eq!(score.crap_exempted, 2);
4703 assert_eq!(score.crap_effective_threshold, Some(500.0));
4704 assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
4705 assert_eq!(
4706 file_score_concern_axis(score, CRAP_THRESHOLD),
4707 FileScoreConcern::Structural
4708 );
4709 }
4710
4711 #[test]
4712 fn compute_file_scores_raised_global_omits_row_threshold() {
4713 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
4714 let files = vec![crate::discover::DiscoveredFile {
4715 id: crate::discover::FileId(0),
4716 path: path_a.clone(),
4717 size_bytes: 100,
4718 }];
4719 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4720 file_id: crate::discover::FileId(0),
4721 path: path_a.clone(),
4722 ..Default::default()
4723 }];
4724 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4725 let modules = vec![make_module_info(
4726 0,
4727 26,
4728 vec![
4729 make_named_fn_complexity("a", 1, 10),
4730 make_named_fn_complexity("b", 12, 10),
4731 ],
4732 )];
4733 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4734 rustc_hash::FxHashMap::default();
4735 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4736 let output = crate::results::DeadCodeAnalysisArtifacts {
4737 results: fallow_types::results::AnalysisResults::default(),
4738 timings: None,
4739 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4740 modules: None,
4741 files: None,
4742 script_used_packages: rustc_hash::FxHashSet::default(),
4743 file_hashes: rustc_hash::FxHashMap::default(),
4744 };
4745
4746 let resolver = test_crap_resolver(5000.0);
4749 let result = compute_file_scores(
4750 FileScoreComputeInput {
4751 modules: &modules,
4752 file_paths: &file_paths,
4753 changed_files: None,
4754 istanbul_coverage: None,
4755 root: std::path::Path::new("/project"),
4756 crap_thresholds: CrapScoreThresholds {
4757 resolver: &resolver,
4758 enforce_crap: true,
4759 },
4760 },
4761 output,
4762 )
4763 .unwrap();
4764
4765 assert_eq!(result.scores.len(), 1);
4766 let score = &result.scores[0];
4767 assert_eq!(score.crap_above_threshold, 0);
4768 assert_eq!(score.crap_exempted, 2);
4769 assert_eq!(score.crap_effective_threshold, None);
4770 assert!(file_score_fully_crap_exempt(score, 5000.0));
4771 assert_eq!(
4772 file_score_concern_axis(score, 5000.0),
4773 FileScoreConcern::Structural
4774 );
4775 }
4776
4777 #[test]
4778 fn crap_scores_empty_complexity() {
4779 let (max, above) = compute_crap_scores_binary(&[], true);
4780 assert!((max).abs() < f64::EPSILON);
4781 assert_eq!(above, 0);
4782 }
4783
4784 #[test]
4785 fn crap_scores_test_reachable() {
4786 let funcs = vec![make_fn_complexity(5)];
4787 let (max, above) = compute_crap_scores_binary(&funcs, true);
4788 assert!((max - 5.0).abs() < f64::EPSILON);
4789 assert_eq!(above, 0);
4790 }
4791
4792 #[test]
4793 fn crap_scores_untested_at_threshold() {
4794 let funcs = vec![make_fn_complexity(5)];
4795 let (max, above) = compute_crap_scores_binary(&funcs, false);
4796 assert!((max - 30.0).abs() < f64::EPSILON);
4797 assert_eq!(above, 1);
4798 }
4799
4800 #[test]
4801 fn crap_scores_untested_above_threshold() {
4802 let funcs = vec![make_fn_complexity(6)];
4803 let (max, above) = compute_crap_scores_binary(&funcs, false);
4804 assert!((max - 42.0).abs() < f64::EPSILON);
4805 assert_eq!(above, 1);
4806 }
4807
4808 #[test]
4809 fn crap_scores_untested_below_threshold() {
4810 let funcs = vec![make_fn_complexity(4)];
4811 let (max, above) = compute_crap_scores_binary(&funcs, false);
4812 assert!((max - 20.0).abs() < f64::EPSILON);
4813 assert_eq!(above, 0);
4814 }
4815
4816 #[test]
4817 fn crap_scores_mixed_functions_untested() {
4818 let funcs = vec![
4819 make_fn_complexity(2),
4820 make_fn_complexity(5),
4821 make_fn_complexity(8),
4822 ];
4823 let (max, above) = compute_crap_scores_binary(&funcs, false);
4824 assert!((max - 72.0).abs() < f64::EPSILON);
4825 assert_eq!(above, 2);
4826 }
4827
4828 #[test]
4829 fn crap_formula_full_coverage() {
4830 let result = crap_formula(10.0, 100.0);
4831 assert!((result - 10.0).abs() < f64::EPSILON);
4832 }
4833
4834 #[test]
4835 fn crap_formula_zero_coverage() {
4836 let result = crap_formula(5.0, 0.0);
4837 assert!((result - 30.0).abs() < f64::EPSILON);
4838 }
4839
4840 #[test]
4841 fn crap_formula_partial_coverage() {
4842 let result = crap_formula(10.0, 50.0);
4843 assert!((result - 22.5).abs() < f64::EPSILON);
4844 }
4845
4846 #[test]
4847 fn crap_formula_high_coverage_low_complexity() {
4848 let result = crap_formula(2.0, 90.0);
4849 assert!((result - 2.004).abs() < 0.001);
4850 }
4851
4852 #[test]
4857 fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
4858 for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
4859 assert!(
4860 crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
4861 "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
4862 );
4863 assert!(
4864 crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
4865 "cyclomatic {} at {coverage_pct}% must stay under the gate",
4866 gate_cc - 1.0
4867 );
4868 }
4869 }
4870
4871 #[test]
4872 fn istanbul_crap_excludes_synthetic_template_units() {
4873 let funcs = vec![
4874 make_named_fn_complexity("<template>", 1, 21),
4875 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
4876 make_fn_complexity(6),
4877 ];
4878 let result = istanbul_crap_default(&funcs, None, false);
4879 assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
4880 assert_eq!(result.signals.above, 1);
4881 assert_eq!(
4882 result.total, 1,
4883 "template units must not count as unmatched"
4884 );
4885 assert_eq!(result.per_function.len(), 1);
4886 }
4887
4888 #[test]
4889 fn estimated_crap_excludes_synthetic_template_units() {
4890 let funcs = vec![
4891 make_named_fn_complexity("<template>", 1, 21),
4892 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
4893 ];
4894 let result = estimated_crap_default(
4895 &funcs,
4896 &rustc_hash::FxHashSet::default(),
4897 false,
4898 fallow_output::CoverageSource::Estimated,
4899 );
4900 assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
4901 assert_eq!(result.signals.above, 0);
4902 assert!(result.per_function.is_empty());
4903 }
4904
4905 #[test]
4906 fn istanbul_crap_with_coverage_data() {
4907 let funcs = vec![make_fn_complexity(10)];
4908 let mut functions = rustc_hash::FxHashMap::default();
4909 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
4910 let file_cov = test_istanbul_file_coverage(functions, false);
4911 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4912 assert!((result.max_crap - 10.8).abs() < 0.1);
4913 assert_eq!(result.signals.above, 0);
4914 }
4915
4916 #[test]
4917 fn istanbul_crap_falls_back_to_binary_when_no_match() {
4918 let funcs = vec![make_fn_complexity(6)];
4919 let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
4920 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4921 assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
4922 assert_eq!(result.signals.above, 1);
4923 }
4924
4925 #[test]
4926 fn istanbul_crap_falls_back_to_binary_when_no_file_coverage() {
4927 let funcs = vec![make_fn_complexity(5)];
4928 let result = istanbul_crap_default(&funcs, None, true);
4929 assert!((result.max_crap - 5.0).abs() < f64::EPSILON);
4930 assert_eq!(result.signals.above, 0);
4931 }
4932
4933 #[test]
4934 fn istanbul_crap_zero_coverage_matches_binary_untested() {
4935 let funcs = vec![make_fn_complexity(5)];
4936 let mut functions = rustc_hash::FxHashMap::default();
4937 functions.insert(("test_fn".to_string(), 1, 0), 0.0);
4938 let file_cov = test_istanbul_file_coverage(functions, false);
4939 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
4940 assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
4941 assert_eq!(result.signals.above, 1);
4942 }
4943
4944 #[test]
4945 fn estimated_crap_direct_test_reference() {
4946 let funcs = vec![make_fn_complexity(10)];
4947 let mut refs = rustc_hash::FxHashSet::default();
4948 refs.insert("test_fn".to_string());
4949 let result = estimated_crap_default(
4950 &funcs,
4951 &refs,
4952 true,
4953 fallow_output::CoverageSource::Estimated,
4954 );
4955 let (max, above) = (result.max_crap, result.signals.above);
4956 assert!((max - 10.3).abs() < 0.1);
4957 assert_eq!(above, 0);
4958 }
4959
4960 #[test]
4961 fn estimated_crap_indirect_test_reachable() {
4962 let funcs = vec![make_fn_complexity(10)];
4963 let refs = rustc_hash::FxHashSet::default();
4964 let result = estimated_crap_default(
4965 &funcs,
4966 &refs,
4967 true,
4968 fallow_output::CoverageSource::Estimated,
4969 );
4970 let (max, above) = (result.max_crap, result.signals.above);
4971 assert!((max - 31.6).abs() < 0.1);
4972 assert_eq!(above, 1);
4973 }
4974
4975 #[test]
4976 fn estimated_crap_untested_file() {
4977 let funcs = vec![make_fn_complexity(5)];
4978 let refs = rustc_hash::FxHashSet::default();
4979 let result = estimated_crap_default(
4980 &funcs,
4981 &refs,
4982 false,
4983 fallow_output::CoverageSource::Estimated,
4984 );
4985 let (max, above) = (result.max_crap, result.signals.above);
4986 assert!((max - 30.0).abs() < f64::EPSILON);
4987 assert_eq!(above, 1);
4988 }
4989
4990 #[test]
4991 fn estimated_crap_low_complexity_direct_ref() {
4992 let funcs = vec![make_fn_complexity(2)];
4993 let mut refs = rustc_hash::FxHashSet::default();
4994 refs.insert("test_fn".to_string());
4995 let result = estimated_crap_default(
4996 &funcs,
4997 &refs,
4998 true,
4999 fallow_output::CoverageSource::Estimated,
5000 );
5001 let (max, above) = (result.max_crap, result.signals.above);
5002 assert!(max < 3.0);
5003 assert_eq!(above, 0);
5004 }
5005
5006 #[test]
5007 fn estimated_crap_empty() {
5008 let refs = rustc_hash::FxHashSet::default();
5009 let result =
5010 estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
5011 let (max, above) = (result.max_crap, result.signals.above);
5012 assert!((max).abs() < f64::EPSILON);
5013 assert_eq!(above, 0);
5014 }
5015
5016 fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
5017 fallow_graph::graph::ExportSymbol {
5018 name: fallow_types::extract::ExportName::Named(name.into()),
5019 is_type_only,
5020 is_side_effect_used: false,
5021 visibility: crate::source::VisibilityTag::None,
5022 expected_unused_reason: None,
5023 span: oxc_span::Span::default(),
5024 references: vec![],
5025 reference_paths: Vec::new(),
5026 members: vec![],
5027 }
5028 }
5029
5030 #[test]
5031 fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
5032 let path = std::path::Path::new("src/types.ts");
5033 let exports = vec![
5034 make_export("MyInterface", true),
5035 make_export("MyType", true),
5036 make_export("myFunction", false),
5037 ];
5038 let unused_files = rustc_hash::FxHashSet::default();
5039 let mut unused_by_path = rustc_hash::FxHashMap::default();
5040 unused_by_path.insert(path, 1_usize); let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5043 assert!((ratio - 1.0).abs() < f64::EPSILON);
5044 }
5045
5046 #[test]
5047 fn dead_code_ratio_only_type_exports_returns_zero() {
5048 let path = std::path::Path::new("src/types.ts");
5049 let exports = vec![
5050 make_export("MyInterface", true),
5051 make_export("MyType", true),
5052 ];
5053 let unused_files = rustc_hash::FxHashSet::default();
5054 let unused_by_path = rustc_hash::FxHashMap::default();
5055
5056 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5057 assert!(ratio.abs() < f64::EPSILON);
5058 }
5059
5060 #[test]
5061 fn dead_code_ratio_mixed_exports_counts_only_values() {
5062 let path = std::path::Path::new("src/component.ts");
5063 let exports = vec![
5064 make_export("Props", true),
5065 make_export("State", true),
5066 make_export("Component", false),
5067 make_export("helper", false),
5068 ];
5069 let unused_files = rustc_hash::FxHashSet::default();
5070 let mut unused_by_path = rustc_hash::FxHashMap::default();
5071 unused_by_path.insert(path, 1_usize);
5072
5073 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5074 assert!((ratio - 0.5).abs() < f64::EPSILON);
5075 }
5076
5077 fn write_single_file_istanbul_fixture(
5078 coverage_path: &std::path::Path,
5079 source_path: &std::path::Path,
5080 fn_map: &serde_json::Value,
5081 function_hits: &serde_json::Value,
5082 ) {
5083 let mut root = serde_json::Map::new();
5084 root.insert(
5085 source_path.to_string_lossy().into_owned(),
5086 serde_json::json!({
5087 "path": source_path.to_string_lossy().into_owned(),
5088 "statementMap": {},
5089 "fnMap": fn_map,
5090 "branchMap": {},
5091 "s": {},
5092 "f": function_hits,
5093 "b": {}
5094 }),
5095 );
5096
5097 std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
5098 }
5099
5100 #[test]
5101 fn resolve_relative_to_root_joins_relative_with_project_root() {
5102 let resolved = resolve_relative_to_root(
5103 std::path::Path::new("coverage/coverage-final.json"),
5104 Some(std::path::Path::new("/work/my-app")),
5105 );
5106 assert_eq!(
5107 resolved,
5108 std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
5109 );
5110 }
5111
5112 #[test]
5113 fn resolve_relative_to_root_returns_absolute_unchanged() {
5114 let resolved = resolve_relative_to_root(
5115 std::path::Path::new("/tmp/coverage-final.json"),
5116 Some(std::path::Path::new("/work/my-app")),
5117 );
5118 assert_eq!(
5119 resolved,
5120 std::path::PathBuf::from("/tmp/coverage-final.json")
5121 );
5122 }
5123
5124 #[test]
5125 fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
5126 let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
5127 let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
5128 assert_eq!(resolved, path);
5129 }
5130
5131 #[cfg(windows)]
5132 #[test]
5133 fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
5134 let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
5135 let resolved =
5136 resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
5137 assert_eq!(resolved, path);
5138 }
5139
5140 #[test]
5141 fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
5142 let resolved =
5143 resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
5144 assert_eq!(
5145 resolved,
5146 std::path::PathBuf::from("coverage/coverage-final.json")
5147 );
5148 }
5149
5150 #[test]
5151 fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
5152 let temp = tempfile::TempDir::new().unwrap();
5153 let source_path = temp.path().join("src/index.ts");
5154 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5155 std::fs::write(&source_path, "export function f(){}").unwrap();
5156
5157 let coverage_path = temp.path().join("coverage/coverage-final.json");
5158 std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
5159 write_single_file_istanbul_fixture(
5160 &coverage_path,
5161 &source_path,
5162 &serde_json::json!({
5163 "0": {
5164 "name": "f",
5165 "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
5166 "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
5167 }
5168 }),
5169 &serde_json::json!({ "0": 1 }),
5170 );
5171
5172 let coverage = load_istanbul_coverage(
5173 std::path::Path::new("coverage/coverage-final.json"),
5174 None,
5175 Some(temp.path()),
5176 false,
5177 )
5178 .expect("relative path must resolve against project_root");
5179 assert!(
5180 !coverage.files.is_empty(),
5181 "expected coverage to load via project_root resolution, got {} files",
5182 coverage.files.len()
5183 );
5184 }
5185
5186 #[test]
5187 fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
5188 let temp = tempfile::TempDir::new().unwrap();
5189 let source_path = temp.path().join("src/service.ts");
5190 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5191 std::fs::write(&source_path, "export class DataService {}\n").unwrap();
5192
5193 let coverage_path = temp.path().join("coverage-final.json");
5194 write_single_file_istanbul_fixture(
5195 &coverage_path,
5196 &source_path,
5197 &serde_json::json!({
5198 "0": {
5199 "name": "(anonymous_0)",
5200 "decl": {
5201 "start": { "line": 5, "column": 2 },
5202 "end": { "line": 5, "column": 13 }
5203 },
5204 "loc": {
5205 "start": { "line": 5, "column": 14 },
5206 "end": { "line": 11, "column": 3 }
5207 }
5208 },
5209 "1": {
5210 "name": "(anonymous_1)",
5211 "decl": {
5212 "start": { "line": 20, "column": 14 },
5213 "end": { "line": 20, "column": 25 }
5214 },
5215 "loc": {
5216 "start": { "line": 20, "column": 28 },
5217 "end": { "line": 22, "column": 2 }
5218 }
5219 }
5220 }),
5221 &serde_json::json!({
5222 "0": 1,
5223 "1": 0
5224 }),
5225 );
5226
5227 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5228 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5229 let file_coverage = coverage.get(&canonical_source).unwrap();
5230
5231 assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
5232 assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
5233 }
5234
5235 #[test]
5236 fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
5237 let temp = tempfile::TempDir::new().unwrap();
5238 let source_path = temp.path().join("src/handler.ts");
5239 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5240 std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
5241
5242 let coverage_path = temp.path().join("coverage-final.json");
5243 write_single_file_istanbul_fixture(
5244 &coverage_path,
5245 &source_path,
5246 &serde_json::json!({
5247 "0": {
5248 "name": "handleClick",
5249 "line": 40,
5250 "decl": {
5251 "start": { "line": 22, "column": 13 },
5252 "end": { "line": 22, "column": 24 }
5253 },
5254 "loc": {
5255 "start": { "line": 40, "column": 27 },
5256 "end": { "line": 42, "column": 1 }
5257 }
5258 }
5259 }),
5260 &serde_json::json!({
5261 "0": 1
5262 }),
5263 );
5264
5265 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5266 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5267 let file_coverage = coverage.get(&canonical_source).unwrap();
5268
5269 assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
5270 assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
5271 }
5272
5273 #[test]
5274 fn load_istanbul_coverage_indexes_valid_body_start_alias() {
5275 let temp = tempfile::TempDir::new().unwrap();
5276 let source_path = temp.path().join("src/handler.ts");
5277 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5278 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
5279
5280 let coverage_path = temp.path().join("coverage-final.json");
5281 write_single_file_istanbul_fixture(
5282 &coverage_path,
5283 &source_path,
5284 &serde_json::json!({
5285 "0": {
5286 "name": "(anonymous_0)",
5287 "line": 8,
5288 "decl": {
5289 "start": { "line": 8, "column": 14 },
5290 "end": { "line": 8, "column": 25 }
5291 },
5292 "loc": {
5293 "start": { "line": 20, "column": 6 },
5294 "end": { "line": 22, "column": 1 }
5295 }
5296 }
5297 }),
5298 &serde_json::json!({ "0": 1 }),
5299 );
5300
5301 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5302 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5303 let file_coverage = coverage.get(&canonical_source).unwrap();
5304
5305 assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
5306
5307 let mut function = make_fn_complexity(4);
5308 function.name = "handler".to_string();
5309 function.line = 20;
5310 function.col = 6;
5311 let result = istanbul_crap_default(&[function], Some(file_coverage), false);
5312 assert_eq!(result.matched, 1);
5313 assert_eq!(result.total, 1);
5314 assert_eq!(
5315 result.per_function[0].coverage_source,
5316 fallow_output::CoverageSource::Istanbul
5317 );
5318 assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
5319 }
5320
5321 #[test]
5322 fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
5323 let temp = tempfile::TempDir::new().unwrap();
5324 let source_path = temp.path().join("src/aliases.ts");
5325 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5326 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
5327
5328 let coverage_path = temp.path().join("coverage-final.json");
5329 write_single_file_istanbul_fixture(
5330 &coverage_path,
5331 &source_path,
5332 &serde_json::json!({
5333 "0": {
5334 "name": "(anonymous_0)",
5335 "line": 10,
5336 "decl": {
5337 "start": { "line": 10, "column": 8 },
5338 "end": { "line": 10, "column": 9 }
5339 },
5340 "loc": {
5341 "start": { "line": 12, "column": 8 },
5342 "end": { "line": 13, "column": 1 }
5343 }
5344 }
5345 }),
5346 &serde_json::json!({ "0": 1 }),
5347 );
5348
5349 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5350 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5351 let file_coverage = coverage.get(&canonical_source).unwrap();
5352
5353 assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
5354 }
5355
5356 #[test]
5361 fn curried_arrow_one_liner_resolves_both_arrows() {
5362 let temp = tempfile::TempDir::new().unwrap();
5363 let source_path = temp.path().join("src/nested.ts");
5364 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5365 std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
5366
5367 let coverage_path = temp.path().join("coverage-final.json");
5368 write_single_file_istanbul_fixture(
5369 &coverage_path,
5370 &source_path,
5371 &serde_json::json!({
5372 "0": {
5373 "name": "(anonymous_0)",
5374 "line": 1,
5375 "decl": {
5376 "start": { "line": 1, "column": 22 },
5377 "end": { "line": 1, "column": 23 }
5378 },
5379 "loc": {
5380 "start": { "line": 1, "column": 28 },
5381 "end": { "line": 1, "column": 38 }
5382 }
5383 },
5384 "1": {
5385 "name": "(anonymous_1)",
5386 "line": 1,
5387 "decl": {
5388 "start": { "line": 1, "column": 28 },
5389 "end": { "line": 1, "column": 29 }
5390 },
5391 "loc": {
5392 "start": { "line": 1, "column": 34 },
5393 "end": { "line": 1, "column": 38 }
5394 }
5395 }
5396 }),
5397 &serde_json::json!({ "0": 1, "1": 0 }),
5398 );
5399
5400 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5401 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5402 let file_coverage = coverage.get(&canonical_source).unwrap();
5403
5404 assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
5405 assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
5406 }
5407
5408 #[test]
5412 fn curried_arrow_multiline_hoc_resolves_both_arrows() {
5413 let temp = tempfile::TempDir::new().unwrap();
5414 let source_path = temp.path().join("src/with-auth.tsx");
5415 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5416 std::fs::write(
5417 &source_path,
5418 "export const withAuth = (Component) =>\n (props) => {\n return Component(props);\n };\n",
5419 )
5420 .unwrap();
5421
5422 let coverage_path = temp.path().join("coverage-final.json");
5423 write_single_file_istanbul_fixture(
5424 &coverage_path,
5425 &source_path,
5426 &serde_json::json!({
5427 "0": {
5428 "name": "(anonymous_0)",
5429 "line": 2,
5430 "decl": {
5431 "start": { "line": 1, "column": 24 },
5432 "end": { "line": 1, "column": 25 }
5433 },
5434 "loc": {
5435 "start": { "line": 2, "column": 2 },
5436 "end": { "line": 4, "column": 3 }
5437 }
5438 },
5439 "1": {
5440 "name": "(anonymous_1)",
5441 "line": 2,
5442 "decl": {
5443 "start": { "line": 2, "column": 2 },
5444 "end": { "line": 2, "column": 3 }
5445 },
5446 "loc": {
5447 "start": { "line": 2, "column": 13 },
5448 "end": { "line": 4, "column": 3 }
5449 }
5450 }
5451 }),
5452 &serde_json::json!({ "0": 1, "1": 0 }),
5453 );
5454
5455 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5456 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5457 let file_coverage = coverage.get(&canonical_source).unwrap();
5458
5459 assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
5460 assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
5461 }
5462
5463 #[test]
5466 fn curried_arrow_depth_three_chain_resolves_every_arrow() {
5467 let temp = tempfile::TempDir::new().unwrap();
5468 let source_path = temp.path().join("src/logger.ts");
5469 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5470 std::fs::write(
5471 &source_path,
5472 "export const logger = (store) => (next) => (action) => {\n return next(action);\n};\n",
5473 )
5474 .unwrap();
5475
5476 let coverage_path = temp.path().join("coverage-final.json");
5477 write_single_file_istanbul_fixture(
5478 &coverage_path,
5479 &source_path,
5480 &serde_json::json!({
5481 "0": {
5482 "name": "(anonymous_0)",
5483 "line": 1,
5484 "decl": {
5485 "start": { "line": 1, "column": 22 },
5486 "end": { "line": 1, "column": 23 }
5487 },
5488 "loc": {
5489 "start": { "line": 1, "column": 33 },
5490 "end": { "line": 3, "column": 1 }
5491 }
5492 },
5493 "1": {
5494 "name": "(anonymous_1)",
5495 "line": 1,
5496 "decl": {
5497 "start": { "line": 1, "column": 33 },
5498 "end": { "line": 1, "column": 34 }
5499 },
5500 "loc": {
5501 "start": { "line": 1, "column": 43 },
5502 "end": { "line": 3, "column": 1 }
5503 }
5504 },
5505 "2": {
5506 "name": "(anonymous_2)",
5507 "line": 1,
5508 "decl": {
5509 "start": { "line": 1, "column": 43 },
5510 "end": { "line": 1, "column": 44 }
5511 },
5512 "loc": {
5513 "start": { "line": 1, "column": 55 },
5514 "end": { "line": 3, "column": 1 }
5515 }
5516 }
5517 }),
5518 &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
5519 );
5520
5521 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5522 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5523 let file_coverage = coverage.get(&canonical_source).unwrap();
5524
5525 assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
5526 assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
5527 assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
5528 }
5529
5530 #[test]
5532 fn curried_class_property_arrow_resolves_both_arrows() {
5533 let temp = tempfile::TempDir::new().unwrap();
5534 let source_path = temp.path().join("src/store.ts");
5535 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5536 std::fs::write(
5537 &source_path,
5538 "export class Store {\n handle = (event) => (payload) => {\n return payload;\n };\n}\n",
5539 )
5540 .unwrap();
5541
5542 let coverage_path = temp.path().join("coverage-final.json");
5543 write_single_file_istanbul_fixture(
5544 &coverage_path,
5545 &source_path,
5546 &serde_json::json!({
5547 "0": {
5548 "name": "(anonymous_0)",
5549 "line": 2,
5550 "decl": {
5551 "start": { "line": 2, "column": 11 },
5552 "end": { "line": 2, "column": 12 }
5553 },
5554 "loc": {
5555 "start": { "line": 2, "column": 22 },
5556 "end": { "line": 4, "column": 3 }
5557 }
5558 },
5559 "1": {
5560 "name": "(anonymous_1)",
5561 "line": 2,
5562 "decl": {
5563 "start": { "line": 2, "column": 22 },
5564 "end": { "line": 2, "column": 23 }
5565 },
5566 "loc": {
5567 "start": { "line": 2, "column": 35 },
5568 "end": { "line": 4, "column": 3 }
5569 }
5570 }
5571 }),
5572 &serde_json::json!({ "0": 1, "1": 0 }),
5573 );
5574
5575 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5576 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5577 let file_coverage = coverage.get(&canonical_source).unwrap();
5578
5579 assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
5580 assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
5581 }
5582
5583 #[test]
5587 fn anonymous_sibling_tie_outside_every_body_abstains() {
5588 let temp = tempfile::TempDir::new().unwrap();
5589 let source_path = temp.path().join("src/handlers.ts");
5590 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5591 std::fs::write(
5592 &source_path,
5593 "export const handlers = {\n a: () => true,\n\n b: () => false,\n};\n",
5594 )
5595 .unwrap();
5596
5597 let coverage_path = temp.path().join("coverage-final.json");
5598 write_single_file_istanbul_fixture(
5599 &coverage_path,
5600 &source_path,
5601 &serde_json::json!({
5602 "0": {
5603 "name": "(anonymous_0)",
5604 "line": 2,
5605 "decl": {
5606 "start": { "line": 2, "column": 5 },
5607 "end": { "line": 2, "column": 6 }
5608 },
5609 "loc": {
5610 "start": { "line": 2, "column": 11 },
5611 "end": { "line": 2, "column": 15 }
5612 }
5613 },
5614 "1": {
5615 "name": "(anonymous_1)",
5616 "line": 4,
5617 "decl": {
5618 "start": { "line": 4, "column": 5 },
5619 "end": { "line": 4, "column": 6 }
5620 },
5621 "loc": {
5622 "start": { "line": 4, "column": 11 },
5623 "end": { "line": 4, "column": 16 }
5624 }
5625 }
5626 }),
5627 &serde_json::json!({ "0": 1, "1": 0 }),
5628 );
5629
5630 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5631 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5632 let file_coverage = coverage.get(&canonical_source).unwrap();
5633
5634 assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
5635 assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
5636 assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
5637 }
5638
5639 #[test]
5645 fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
5646 let temp = tempfile::TempDir::new().unwrap();
5647 let source_path = temp.path().join("src/nested.ts");
5648 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5649 std::fs::write(
5650 &source_path,
5651 "export const o = function () { const f = () => { return 1;\n }; return f; };\n",
5652 )
5653 .unwrap();
5654
5655 let coverage_path = temp.path().join("coverage-final.json");
5656 write_single_file_istanbul_fixture(
5657 &coverage_path,
5658 &source_path,
5659 &serde_json::json!({
5660 "0": {
5661 "name": "(anonymous_0)",
5662 "line": 1,
5663 "decl": {
5664 "start": { "line": 1, "column": 17 },
5665 "end": { "line": 1, "column": 18 }
5666 },
5667 "loc": {
5668 "start": { "line": 1, "column": 29 },
5669 "end": { "line": 2, "column": 53 }
5670 }
5671 },
5672 "1": {
5673 "name": "(anonymous_1)",
5674 "line": 1,
5675 "decl": {
5676 "start": { "line": 1, "column": 41 },
5677 "end": { "line": 1, "column": 42 }
5678 },
5679 "loc": {
5680 "start": { "line": 1, "column": 47 },
5681 "end": { "line": 2, "column": 40 }
5682 }
5683 }
5684 }),
5685 &serde_json::json!({ "0": 1, "1": 0 }),
5686 );
5687
5688 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5689 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5690 let file_coverage = coverage.get(&canonical_source).unwrap();
5691
5692 assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
5693 }
5694
5695 #[test]
5698 fn anonymous_tie_rejects_incomparable_containing_spans() {
5699 let file_coverage = IstanbulFileCoverage::new(
5700 vec![
5701 IstanbulFunctionCoverage {
5702 name: "(anonymous_0)".to_string(),
5703 coverage_pct: 100.0,
5704 aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
5705 body_span: Some(body_span((10, 14), (12, 30))),
5706 },
5707 IstanbulFunctionCoverage {
5708 name: "(anonymous_1)".to_string(),
5709 coverage_pct: 0.0,
5710 aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
5711 body_span: Some(body_span((11, 0), (14, 0))),
5712 },
5713 ],
5714 false,
5715 );
5716
5717 assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
5718 }
5719
5720 #[test]
5725 fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
5726 let file_coverage = IstanbulFileCoverage::new(
5727 vec![
5728 IstanbulFunctionCoverage {
5729 name: "(anonymous_0)".to_string(),
5730 coverage_pct: 100.0,
5731 aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
5732 body_span: Some(body_span((4, 11), (4, 23))),
5733 },
5734 IstanbulFunctionCoverage {
5735 name: "(anonymous_1)".to_string(),
5736 coverage_pct: 0.0,
5737 aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
5738 body_span: Some(body_span((4, 18), (4, 23))),
5739 },
5740 ],
5741 false,
5742 );
5743
5744 assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
5745 assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
5746 }
5747
5748 #[test]
5751 fn colliding_secondary_aliases_abstain_at_shared_position() {
5752 let file_coverage = IstanbulFileCoverage::new(
5753 vec![
5754 IstanbulFunctionCoverage {
5755 name: "(anonymous_0)".to_string(),
5756 coverage_pct: 100.0,
5757 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
5758 body_span: Some(body_span((12, 4), (20, 0))),
5759 },
5760 IstanbulFunctionCoverage {
5761 name: "(anonymous_1)".to_string(),
5762 coverage_pct: 0.0,
5763 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
5764 body_span: Some(body_span((12, 4), (18, 0))),
5765 },
5766 ],
5767 false,
5768 );
5769
5770 assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
5771 assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
5772 assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
5773 }
5774
5775 #[test]
5776 fn colliding_named_secondary_aliases_abstain_at_shared_position() {
5777 let file_coverage = IstanbulFileCoverage::new(
5778 vec![
5779 IstanbulFunctionCoverage {
5780 name: "handler".to_string(),
5781 coverage_pct: 100.0,
5782 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
5783 body_span: Some(body_span((12, 4), (20, 0))),
5784 },
5785 IstanbulFunctionCoverage {
5786 name: "handler".to_string(),
5787 coverage_pct: 0.0,
5788 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
5789 body_span: Some(body_span((12, 4), (18, 0))),
5790 },
5791 ],
5792 false,
5793 );
5794
5795 assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
5796 assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
5797 assert!(file_coverage.lookup("handler", 12, 4).is_none());
5798 }
5799
5800 #[test]
5801 fn invalid_body_location_does_not_create_an_alias() {
5802 let temp = tempfile::TempDir::new().unwrap();
5803 let source_path = temp.path().join("src/invalid-location.ts");
5804 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5805 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
5806
5807 let coverage_path = temp.path().join("coverage-final.json");
5808 write_single_file_istanbul_fixture(
5809 &coverage_path,
5810 &source_path,
5811 &serde_json::json!({
5812 "0": {
5813 "name": "(anonymous_0)",
5814 "line": 8,
5815 "decl": {
5816 "start": { "line": 8, "column": 14 },
5817 "end": { "line": 8, "column": 25 }
5818 },
5819 "loc": {
5820 "start": { "line": 22, "column": 1 },
5821 "end": { "line": 20, "column": 6 }
5822 }
5823 }
5824 }),
5825 &serde_json::json!({ "0": 1 }),
5826 );
5827
5828 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5829 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5830 let file_coverage = coverage.get(&canonical_source).unwrap();
5831
5832 assert!(file_coverage.lookup("handler", 22, 1).is_none());
5833 }
5834
5835 #[test]
5836 fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
5837 let temp = tempfile::TempDir::new().unwrap();
5838 let source_path = temp.path().join("src/actor.ts");
5839 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5840 std::fs::write(
5841 &source_path,
5842 "export const elementsFrom = async (\n locator: AnyLocator,\n options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n return [];\n};\n",
5843 )
5844 .unwrap();
5845
5846 let coverage_path = temp.path().join("coverage-final.json");
5847 write_single_file_istanbul_fixture(
5848 &coverage_path,
5849 &source_path,
5850 &serde_json::json!({
5851 "0": {
5852 "name": "(anonymous_0)",
5853 "line": 4,
5854 "decl": {
5855 "start": { "line": 1, "column": 28 },
5856 "end": { "line": 4, "column": 26 }
5857 },
5858 "loc": {
5859 "start": { "line": 4, "column": 27 },
5860 "end": { "line": 6, "column": 1 }
5861 }
5862 }
5863 }),
5864 &serde_json::json!({
5865 "0": 642
5866 }),
5867 );
5868
5869 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5870 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5871 let file_coverage = coverage.get(&canonical_source).unwrap();
5872
5873 assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
5874 }
5875
5876 #[test]
5877 fn istanbul_lookup_exact_match() {
5878 let mut functions = rustc_hash::FxHashMap::default();
5879 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
5880 let fc = test_istanbul_file_coverage(functions, false);
5881 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
5882 }
5883
5884 #[test]
5885 fn istanbul_lookup_fuzzy_match_within_offset() {
5886 let mut functions = rustc_hash::FxHashMap::default();
5887 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
5888 let fc = test_istanbul_file_coverage(functions, false);
5889 assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
5890 assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
5891 }
5892
5893 #[test]
5894 fn istanbul_lookup_fuzzy_match_outside_offset() {
5895 let mut functions = rustc_hash::FxHashMap::default();
5896 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
5897 let fc = test_istanbul_file_coverage(functions, false);
5898 assert!(fc.lookup("handleClick", 13, 0).is_none());
5899 }
5900
5901 #[test]
5902 fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
5903 let mut functions = rustc_hash::FxHashMap::default();
5904 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
5905 let fc = test_istanbul_file_coverage(functions, true);
5906 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
5907 }
5908
5909 #[test]
5910 fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
5911 let mut functions = rustc_hash::FxHashMap::default();
5912 functions.insert(("handleClick".to_string(), 29, 16), 72.0);
5913 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
5914 let fc = test_istanbul_file_coverage(functions, true);
5915 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
5916 }
5917
5918 #[test]
5919 fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
5920 let mut functions = rustc_hash::FxHashMap::default();
5921 functions.insert(("render".to_string(), 29, 0), 72.0);
5922 functions.insert(("render".to_string(), 80, 0), 10.0);
5923 let fc = test_istanbul_file_coverage(functions, true);
5924 assert!(fc.lookup("render", 10, 0).is_none());
5925 }
5926
5927 #[test]
5928 fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
5929 let mut functions = rustc_hash::FxHashMap::default();
5930 functions.insert(("render".to_string(), 11, 0), 72.0);
5931 functions.insert(("render".to_string(), 80, 0), 10.0);
5932 let fc = test_istanbul_file_coverage(functions, true);
5933 assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
5934 }
5935
5936 #[test]
5937 fn istanbul_lookup_name_mismatch() {
5938 let mut functions = rustc_hash::FxHashMap::default();
5939 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
5940 let fc = test_istanbul_file_coverage(functions, false);
5941 assert!(fc.lookup("handleSubmit", 10, 0).is_none());
5942 }
5943
5944 #[test]
5945 fn istanbul_lookup_empty() {
5946 let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
5947 assert!(fc.lookup("anything", 1, 0).is_none());
5948 }
5949
5950 #[test]
5951 fn istanbul_lookup_fuzzy_picks_closest() {
5952 let mut functions = rustc_hash::FxHashMap::default();
5953 functions.insert(("render".to_string(), 8, 0), 60.0);
5954 functions.insert(("render".to_string(), 12, 0), 90.0);
5955 let fc = test_istanbul_file_coverage(functions, false);
5956 let result = fc.lookup("render", 10, 0);
5957 assert!(result.is_some());
5958 let pct = result.unwrap();
5959 assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
5960 }
5961
5962 #[test]
5963 fn istanbul_lookup_anonymous_fallback_single_candidate() {
5964 let mut functions = rustc_hash::FxHashMap::default();
5965 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
5966 let fc = test_istanbul_file_coverage(functions, false);
5967 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
5968 assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
5969 }
5970
5971 #[test]
5972 fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
5973 let mut functions = rustc_hash::FxHashMap::default();
5974 functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
5975 let fc = test_istanbul_file_coverage(functions, false);
5976
5977 assert!(fc.lookup("declaredHelper", 3, 0).is_none());
5978 }
5979
5980 #[test]
5981 fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
5982 let mut functions = rustc_hash::FxHashMap::default();
5983 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
5984 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
5985 let fc = test_istanbul_file_coverage(functions, false);
5986 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
5987 }
5988
5989 #[test]
5990 fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
5991 let mut functions = rustc_hash::FxHashMap::default();
5992 functions.insert(("(anonymous_0)".to_string(), 1, 23), 90.0); functions.insert(("(anonymous_1)".to_string(), 1, 43), 10.0); let fc = test_istanbul_file_coverage(functions, false);
5995 assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
5996 assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
5997 }
5998
5999 #[test]
6000 fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
6001 let mut functions = rustc_hash::FxHashMap::default();
6002 functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
6003 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
6004 let fc = test_istanbul_file_coverage(functions, false);
6005 assert!(fc.lookup("myHandler", 28, 0).is_none());
6006 }
6007
6008 #[test]
6009 fn istanbul_lookup_anonymous_fallback_outside_offset() {
6010 let mut functions = rustc_hash::FxHashMap::default();
6011 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
6012 let fc = test_istanbul_file_coverage(functions, false);
6013 assert!(fc.lookup("myHandler", 31, 0).is_none());
6014 }
6015
6016 #[test]
6017 fn istanbul_lookup_named_match_beats_nearby_anonymous() {
6018 let mut functions = rustc_hash::FxHashMap::default();
6019 functions.insert(("handleClick".to_string(), 10, 0), 90.0);
6020 functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
6021 let fc = test_istanbul_file_coverage(functions, false);
6022 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
6023 }
6024
6025 #[test]
6026 fn build_test_refs_empty() {
6027 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
6028 let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
6029 let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
6030 assert!(refs.is_empty());
6031 }
6032
6033 #[test]
6034 fn build_test_refs_empty_inputs() {
6035 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
6036 let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
6037 let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
6038 assert!(refs.is_empty());
6039 }
6040
6041 #[test]
6042 fn istanbul_crap_empty_complexity() {
6043 let result = istanbul_crap_default(&[], None, false);
6044 assert!((result.max_crap).abs() < f64::EPSILON);
6045 assert_eq!(result.signals.above, 0);
6046 assert_eq!(result.matched, 0);
6047 assert_eq!(result.total, 0);
6048 }
6049
6050 #[test]
6051 fn istanbul_crap_match_statistics() {
6052 let funcs = vec![make_fn_complexity(5), {
6053 let mut f = make_fn_complexity(3);
6054 f.name = "other_fn".into();
6055 f.line = 10;
6056 f
6057 }];
6058 let mut functions = rustc_hash::FxHashMap::default();
6059 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
6060 let file_cov = test_istanbul_file_coverage(functions, false);
6061 let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
6062 assert_eq!(result.matched, 1);
6063 assert_eq!(result.total, 2);
6064 }
6065
6066 #[test]
6067 fn estimated_crap_multiple_functions_mixed_coverage() {
6068 let funcs = vec![
6069 make_fn_complexity(10), {
6071 let mut f = make_fn_complexity(3);
6072 f.name = "helper".into();
6073 f.line = 20;
6074 f
6075 },
6076 ];
6077 let mut refs = rustc_hash::FxHashSet::default();
6078 refs.insert("test_fn".to_string());
6079 let result = estimated_crap_default(
6080 &funcs,
6081 &refs,
6082 true,
6083 fallow_output::CoverageSource::Estimated,
6084 );
6085 let (max, above) = (result.max_crap, result.signals.above);
6086 assert!(max > 10.0);
6087 assert_eq!(above, 0);
6088 }
6089
6090 #[test]
6091 fn binary_crap_test_reachable() {
6092 let funcs = vec![make_fn_complexity(10)];
6093 let (max, above) = compute_crap_scores_binary(&funcs, true);
6094 assert!((max - 10.0).abs() < f64::EPSILON);
6095 assert_eq!(above, 0);
6096 }
6097
6098 #[test]
6099 fn binary_crap_not_reachable() {
6100 let funcs = vec![make_fn_complexity(6)];
6101 let (max, above) = compute_crap_scores_binary(&funcs, false);
6102 assert!((max - 42.0).abs() < f64::EPSILON);
6103 assert_eq!(above, 1);
6104 }
6105
6106 #[test]
6107 fn binary_crap_threshold_boundary() {
6108 let funcs = vec![make_fn_complexity(5)];
6109 let (max, above) = compute_crap_scores_binary(&funcs, false);
6110 assert!((max - 30.0).abs() < f64::EPSILON);
6111 assert_eq!(above, 1);
6112 }
6113
6114 #[test]
6115 fn binary_crap_empty() {
6116 let (max, above) = compute_crap_scores_binary(&[], true);
6117 assert!((max).abs() < f64::EPSILON);
6118 assert_eq!(above, 0);
6119 }
6120
6121 #[test]
6122 fn binary_crap_multiple_functions() {
6123 let funcs = vec![make_fn_complexity(3), make_fn_complexity(8)];
6124 let (max, above) = compute_crap_scores_binary(&funcs, false);
6125 assert!((max - 72.0).abs() < f64::EPSILON);
6126 assert_eq!(above, 1);
6127 }
6128}