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) istanbul_files_joined: usize,
52 pub(crate) istanbul_files_total: usize,
54 pub(crate) per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
58 pub(crate) template_inherit_provenance:
65 rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
66}
67
68struct FileScoreOutputParts<'a> {
69 graph: &'a fallow_graph::graph::ModuleGraph,
70 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
71 results: &'a crate::results::AnalysisResults,
72 scores: Vec<FileHealthScore>,
73 coverage: CoverageGapData,
74 circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
75 top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
76 entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
77 value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
78 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
79 cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
80 direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
81 istanbul_matched: usize,
82 istanbul_total: usize,
83 istanbul_files_joined: usize,
84 istanbul_files_total: usize,
85 per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
86 template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
87}
88
89#[derive(Clone, Default)]
95pub struct AnalysisCountsSnapshot {
96 unused_file_paths: Vec<std::path::PathBuf>,
98 unused_export_paths: Vec<std::path::PathBuf>,
101 unused_dep_package_paths: Vec<std::path::PathBuf>,
105 circular_dep_groups: Vec<Vec<std::path::PathBuf>>,
108 module_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
111}
112
113impl AnalysisCountsSnapshot {
114 pub(crate) fn counts_for(
130 &self,
131 subset: &crate::health::SubsetFilter<'_>,
132 defaults: &crate::vital_signs::AnalysisCounts,
133 ) -> crate::vital_signs::AnalysisCounts {
134 if subset.is_full() {
135 return *defaults;
136 }
137 let dead_files = self
138 .unused_file_paths
139 .iter()
140 .filter(|p| subset.matches(p))
141 .count();
142 let dead_exports = self
143 .unused_export_paths
144 .iter()
145 .filter(|p| subset.matches(p))
146 .count();
147 let unused_deps = self
148 .unused_dep_package_paths
149 .iter()
150 .filter(|dep_path| dep_in_subset(subset, dep_path))
151 .count();
152 let circular_deps = self
153 .circular_dep_groups
154 .iter()
155 .filter(|cycle| cycle.iter().any(|p| subset.matches(p)))
156 .count();
157 let total_exports = self
158 .module_export_counts
159 .iter()
160 .filter(|(p, _)| subset.matches(p))
161 .map(|(_, n)| *n)
162 .sum();
163 crate::vital_signs::AnalysisCounts {
164 total_exports,
165 dead_files,
166 dead_exports,
167 unused_deps,
168 circular_deps,
169 total_deps: defaults.total_deps,
170 }
171 }
172}
173
174fn dep_in_subset(subset: &crate::health::SubsetFilter<'_>, dep_path: &std::path::Path) -> bool {
181 match subset {
182 crate::health::SubsetFilter::Full => true,
183 crate::health::SubsetFilter::Paths(set) => {
184 let Some(workspace_root) = dep_path.parent() else {
185 return false;
186 };
187 set.iter().any(|p| p.starts_with(workspace_root))
188 }
189 }
190}
191
192#[expect(
202 clippy::cast_possible_truncation,
203 reason = "line count is bounded by source file size"
204)]
205fn aggregate_complexity(module: &crate::source::ModuleInfo) -> (u32, u32, usize, u32) {
206 let cyc: u32 = module
207 .complexity
208 .iter()
209 .map(|f| u32::from(f.cyclomatic))
210 .sum();
211 let cog: u32 = module
212 .complexity
213 .iter()
214 .map(|f| u32::from(f.cognitive))
215 .sum();
216 let funcs = module.complexity.len();
217 let lines = module.line_offsets.len() as u32;
218 (cyc, cog, funcs, lines)
219}
220
221fn compute_dead_code_ratio(
229 path: &std::path::Path,
230 exports: &[fallow_graph::graph::ExportSymbol],
231 unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
232 unused_exports_by_path: &rustc_hash::FxHashMap<&std::path::Path, usize>,
233) -> f64 {
234 if unused_files.contains(path) {
235 return 1.0;
236 }
237 let value_exports = exports.iter().filter(|e| !e.is_type_only).count();
238 if value_exports == 0 {
239 return 0.0;
240 }
241 let unused = unused_exports_by_path.get(path).copied().unwrap_or(0);
242 (unused as f64 / value_exports as f64).min(1.0)
243}
244
245fn compute_complexity_density(total_cyclomatic: u32, lines: u32) -> f64 {
249 if lines > 0 {
250 f64::from(total_cyclomatic) / f64::from(lines)
251 } else {
252 0.0
253 }
254}
255
256pub(super) const CRAP_THRESHOLD: f64 = 30.0;
259
260#[derive(Clone, Copy)]
263pub(super) struct CrapScoreThresholds<'a> {
264 pub(super) resolver: &'a ThresholdOverrideResolver,
265 pub(super) enforce_crap: bool,
266}
267
268pub(super) struct CrapCeilingLookup<'a> {
275 resolver: &'a ThresholdOverrideResolver,
276 relative: &'a std::path::Path,
277 enforce_crap: bool,
278}
279
280#[derive(Debug, Default)]
287struct CrapThresholdSignals {
288 above: usize,
290 exempted: usize,
294 min_ceiling: Option<f64>,
296}
297
298impl<'a> CrapCeilingLookup<'a> {
299 pub(super) fn new(thresholds: CrapScoreThresholds<'a>, relative: &'a std::path::Path) -> Self {
300 Self {
301 resolver: thresholds.resolver,
302 relative,
303 enforce_crap: thresholds.enforce_crap,
304 }
305 }
306
307 fn observe(&self, function: &str, crap_rounded: f64, signals: &mut CrapThresholdSignals) {
310 let ceiling = self.resolver.effective_max_crap(self.relative, function);
311 signals.min_ceiling = Some(signals.min_ceiling.map_or(ceiling, |m| m.min(ceiling)));
312 if !self.enforce_crap {
313 if crap_rounded >= CRAP_THRESHOLD {
314 signals.exempted += 1;
315 }
316 } else if crap_rounded >= ceiling {
317 signals.above += 1;
318 } else if crap_rounded >= CRAP_THRESHOLD {
319 signals.exempted += 1;
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy)]
326pub struct PerFunctionCrap {
327 pub(crate) line: u32,
329 pub(crate) col: u32,
335 pub(crate) crap: f64,
337 pub(crate) coverage_pct: Option<f64>,
340 pub(crate) coverage_tier: fallow_output::CoverageTier,
344 pub(crate) coverage_source: fallow_output::CoverageSource,
351}
352
353#[derive(Debug)]
355struct IstanbulCrapResult {
356 pub max_crap: f64,
357 pub signals: CrapThresholdSignals,
359 pub matched: usize,
361 pub total: usize,
363 pub per_function: Vec<PerFunctionCrap>,
365}
366
367fn compute_crap_scores_istanbul(
378 complexity: &[fallow_types::extract::FunctionComplexity],
379 file_coverage: Option<&IstanbulFileCoverage>,
380 is_test_reachable: bool,
381 ceilings: &CrapCeilingLookup<'_>,
382) -> IstanbulCrapResult {
383 if complexity.is_empty() {
384 return IstanbulCrapResult {
385 max_crap: 0.0,
386 signals: CrapThresholdSignals::default(),
387 matched: 0,
388 total: 0,
389 per_function: Vec::new(),
390 };
391 }
392 let mut max = 0.0_f64;
393 let mut signals = CrapThresholdSignals::default();
394 let mut matched = 0usize;
395 let mut total = 0usize;
396 let mut per_function = Vec::with_capacity(complexity.len());
397 for f in complexity {
398 if fallow_types::extract::is_synthetic_template_unit(&f.name)
407 || fallow_types::extract::is_synthetic_module_unit(&f.name)
408 {
409 continue;
410 }
411 total += 1;
412 let (crap, coverage_pct, tier, source) =
413 crap_for_function(f, file_coverage, is_test_reachable, &mut matched);
414 let crap_rounded = (crap * 10.0).round() / 10.0;
415 max = max.max(crap);
416 ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
417 per_function.push(PerFunctionCrap {
418 line: f.line,
419 col: f.col,
420 crap: crap_rounded,
421 coverage_pct,
422 coverage_tier: tier,
423 coverage_source: source,
424 });
425 }
426 IstanbulCrapResult {
427 max_crap: (max * 10.0).round() / 10.0,
428 signals,
429 matched,
430 total,
431 per_function,
432 }
433}
434
435#[expect(
439 clippy::suboptimal_flops,
440 reason = "cc * cc + cc matches the CRAP formula specification"
441)]
442fn crap_for_function(
443 f: &fallow_types::extract::FunctionComplexity,
444 file_coverage: Option<&IstanbulFileCoverage>,
445 is_test_reachable: bool,
446 matched: &mut usize,
447) -> (
448 f64,
449 Option<f64>,
450 fallow_output::CoverageTier,
451 fallow_output::CoverageSource,
452) {
453 let cc = f64::from(f.cyclomatic);
454 let lookup = file_coverage.and_then(|fc| fc.lookup_function(f));
455 if let Some(cov_pct) = lookup {
456 *matched += 1;
457 return (
458 crap_formula(cc, cov_pct),
459 Some(cov_pct),
460 fallow_output::CoverageTier::from_pct(cov_pct),
461 fallow_output::CoverageSource::Istanbul,
462 );
463 }
464 if is_test_reachable {
468 return (
469 crap_formula(cc, INDIRECT_TEST_COVERAGE_ESTIMATE),
470 None,
471 fallow_output::CoverageTier::from_pct(INDIRECT_TEST_COVERAGE_ESTIMATE),
472 fallow_output::CoverageSource::Estimated,
473 );
474 }
475 (
476 cc * cc + cc,
477 None,
478 fallow_output::CoverageTier::None,
479 fallow_output::CoverageSource::Estimated,
480 )
481}
482
483const DIRECT_TEST_COVERAGE_ESTIMATE: f64 = 85.0;
486
487const INDIRECT_TEST_COVERAGE_ESTIMATE: f64 = 40.0;
491const MAX_DIRECT_CALLER_EVIDENCE: usize = 5;
492
493#[derive(Debug)]
504struct EstimatedCrapResult {
505 pub max_crap: f64,
506 pub signals: CrapThresholdSignals,
508 pub per_function: Vec<PerFunctionCrap>,
509}
510
511fn compute_crap_scores_estimated(
512 complexity: &[fallow_types::extract::FunctionComplexity],
513 test_referenced_exports: &rustc_hash::FxHashSet<String>,
514 is_test_reachable: bool,
515 coverage_source: fallow_output::CoverageSource,
516 ceilings: &CrapCeilingLookup<'_>,
517) -> EstimatedCrapResult {
518 if complexity.is_empty() {
519 return EstimatedCrapResult {
520 max_crap: 0.0,
521 signals: CrapThresholdSignals::default(),
522 per_function: Vec::new(),
523 };
524 }
525 let mut max = 0.0_f64;
526 let mut signals = CrapThresholdSignals::default();
527 let mut per_function = Vec::with_capacity(complexity.len());
528 for f in complexity {
529 if fallow_types::extract::is_synthetic_template_unit(&f.name)
534 || fallow_types::extract::is_synthetic_module_unit(&f.name)
535 {
536 continue;
537 }
538 let cc = f64::from(f.cyclomatic);
539 let estimated_coverage = if test_referenced_exports.contains(f.name.as_str()) {
540 DIRECT_TEST_COVERAGE_ESTIMATE
541 } else if is_test_reachable {
542 INDIRECT_TEST_COVERAGE_ESTIMATE
543 } else {
544 0.0
545 };
546 let crap = crap_formula(cc, estimated_coverage);
547 let crap_rounded = (crap * 10.0).round() / 10.0;
548 max = max.max(crap);
549 ceilings.observe(f.name.as_str(), crap_rounded, &mut signals);
550 per_function.push(PerFunctionCrap {
551 line: f.line,
552 col: f.col,
553 crap: crap_rounded,
554 coverage_pct: None,
555 coverage_tier: fallow_output::CoverageTier::from_pct(estimated_coverage),
556 coverage_source,
557 });
558 }
559 EstimatedCrapResult {
560 max_crap: (max * 10.0).round() / 10.0,
561 signals,
562 per_function,
563 }
564}
565
566#[derive(Debug, Clone)]
580pub(super) struct TemplateInheritContext {
581 pub is_test_reachable: bool,
582 pub test_referenced_exports: rustc_hash::FxHashSet<String>,
583 pub provenance_owner: std::path::PathBuf,
588}
589
590fn build_template_inherit_contexts(
612 graph: &fallow_graph::graph::ModuleGraph,
613 test_coverage: StaticTestCoverage<'_>,
614 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
615 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
616) -> rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext> {
617 let mut out = rustc_hash::FxHashMap::default();
618 for node in &graph.modules {
619 if let Some(context) =
620 template_inherit_context_for_node(node, graph, test_coverage, module_by_id, file_paths)
621 {
622 out.insert(node.file_id, context);
623 }
624 }
625 out
626}
627
628fn template_inherit_context_for_node(
629 node: &fallow_graph::graph::ModuleNode,
630 graph: &fallow_graph::graph::ModuleGraph,
631 test_coverage: StaticTestCoverage<'_>,
632 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
633 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
634) -> Option<TemplateInheritContext> {
635 if !is_template_inherit_candidate(node, module_by_id, file_paths) {
636 return None;
637 }
638 let importers = graph.reverse_deps.get(node.file_id.0 as usize)?;
639 template_inherit_context_from_importers(
640 importers,
641 graph,
642 test_coverage,
643 module_by_id,
644 file_paths,
645 )
646}
647
648fn is_template_inherit_candidate(
649 node: &fallow_graph::graph::ModuleNode,
650 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
651 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
652) -> bool {
653 let Some(path) = file_paths.get(&node.file_id) else {
654 return false;
655 };
656 if !path
657 .extension()
658 .and_then(|ext| ext.to_str())
659 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
660 {
661 return false;
662 }
663 module_by_id.get(&node.file_id).is_some_and(|module| {
664 module
665 .complexity
666 .iter()
667 .any(|finding| finding.name.as_str() == "<template>")
668 })
669}
670
671fn template_inherit_context_from_importers(
672 importers: &[crate::discover::FileId],
673 graph: &fallow_graph::graph::ModuleGraph,
674 test_coverage: StaticTestCoverage<'_>,
675 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
676 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
677) -> Option<TemplateInheritContext> {
678 let mut any_reachable = false;
679 let mut combined_refs = rustc_hash::FxHashSet::default();
680 let mut provenance: Option<std::path::PathBuf> = None;
681 let mut first_owner: Option<std::path::PathBuf> = None;
682
683 for &importer_id in importers {
684 let Some((owner_node, owner_path)) =
685 template_owner(importer_id, graph, module_by_id, file_paths)
686 else {
687 continue;
688 };
689 if first_owner.is_none() {
690 first_owner = Some((*owner_path).clone());
691 }
692 if test_coverage.covers_file(owner_node.file_id) {
693 any_reachable = true;
694 provenance.get_or_insert_with(|| (*owner_path).clone());
695 let refs = build_test_referenced_exports(&owner_node.exports, test_coverage);
696 combined_refs.extend(refs);
697 }
698 }
699
700 let provenance_owner = provenance.or(first_owner)?;
701 Some(TemplateInheritContext {
702 is_test_reachable: any_reachable,
703 test_referenced_exports: combined_refs,
704 provenance_owner,
705 })
706}
707
708fn template_owner<'a>(
709 importer_id: crate::discover::FileId,
710 graph: &'a fallow_graph::graph::ModuleGraph,
711 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
712 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
713) -> Option<(&'a fallow_graph::graph::ModuleNode, &'a std::path::PathBuf)> {
714 let owner_node = graph.modules.get(importer_id.0 as usize)?;
715 let owner_path = *file_paths.get(&importer_id)?;
716 if !is_template_owner_path(owner_path) || graph.test_entry_points.contains(&importer_id) {
717 return None;
718 }
719 let owner_has_component = module_by_id
720 .get(&importer_id)
721 .is_some_and(|module| module.has_angular_component_template_url);
722 owner_has_component.then_some((owner_node, owner_path))
723}
724
725fn is_template_owner_path(path: &std::path::Path) -> bool {
726 path.extension()
727 .and_then(|ext| ext.to_str())
728 .is_some_and(|ext| {
729 matches!(
730 ext.to_ascii_lowercase().as_str(),
731 "ts" | "tsx" | "mts" | "cts"
732 )
733 })
734}
735
736fn build_test_referenced_exports(
741 exports: &[fallow_graph::graph::ExportSymbol],
742 test_coverage: StaticTestCoverage<'_>,
743) -> rustc_hash::FxHashSet<String> {
744 let mut set = rustc_hash::FxHashSet::default();
745 for export in exports {
746 if export.is_type_only {
747 continue;
748 }
749 let has_test_ref = test_coverage.covers_any_reference(export);
750 if has_test_ref {
751 set.insert(export.name.to_string());
752 }
753 }
754 set
755}
756
757fn collect_direct_callers(
758 graph: &fallow_graph::graph::ModuleGraph,
759 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
760) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>> {
761 let mut callers_by_target = rustc_hash::FxHashMap::default();
762 for node in &graph.modules {
763 let Some(target_path) = file_paths.get(&node.file_id) else {
764 continue;
765 };
766 let mut callers = graph
767 .direct_importer_summaries(node.file_id)
768 .into_iter()
769 .filter_map(|summary| {
770 file_paths
771 .get(&summary.source)
772 .map(|caller_path| DirectCallerEvidence {
773 path: (*caller_path).clone(),
774 symbols: summary
775 .symbols
776 .into_iter()
777 .map(|symbol| DirectCallerSymbolEvidence {
778 imported: symbol.imported,
779 local: symbol.local,
780 type_only: symbol.type_only,
781 })
782 .collect(),
783 })
784 })
785 .collect::<Vec<_>>();
786 callers.sort_by(|a, b| a.path.cmp(&b.path));
787 callers.truncate(MAX_DIRECT_CALLER_EVIDENCE);
788 if !callers.is_empty() {
789 callers_by_target.insert((*target_path).clone(), callers);
790 }
791 }
792 callers_by_target
793}
794
795#[expect(
798 clippy::suboptimal_flops,
799 reason = "explicit multiplication matches the CRAP formula specification"
800)]
801fn crap_formula(cc: f64, coverage_pct: f64) -> f64 {
802 let uncovered = 1.0 - coverage_pct / 100.0;
803 cc * cc * uncovered * uncovered * uncovered + cc
804}
805
806const ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT: u32 = 16;
812
813const ALIAS_FUZZ_MAX_LINE_DRIFT: u32 = 2;
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(
846 fn_entry: &oxc_coverage_instrument::FnEntry,
847 source_index: Option<&IstanbulSourceIndex<'_>>,
848 ) -> Option<Self> {
849 let start = normalized_istanbul_position(
850 fn_entry.loc.start.line,
851 fn_entry.loc.start.column,
852 source_index,
853 )?;
854 let end = normalized_istanbul_position(
855 fn_entry.loc.end.line,
856 fn_entry.loc.end.column,
857 source_index,
858 )?;
859 (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
860 }
861
862 fn header_from_entry(
863 fn_entry: &oxc_coverage_instrument::FnEntry,
864 source_index: Option<&IstanbulSourceIndex<'_>>,
865 ) -> Option<Self> {
866 let start = normalized_istanbul_position(
867 fn_entry.decl.start.line,
868 fn_entry.decl.start.column,
869 source_index,
870 )?;
871 let end = normalized_istanbul_position(
872 fn_entry.loc.start.line,
873 fn_entry.loc.start.column,
874 source_index,
875 )?;
876 (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
877 }
878
879 fn contains(self, position: IstanbulPosition) -> bool {
881 self.start <= position && position < self.end
882 }
883
884 fn strictly_contains(self, other: Self) -> bool {
885 self != other && self.start <= other.start && other.end <= self.end
886 }
887}
888
889#[derive(Clone, Copy, Debug, Eq, PartialEq)]
898struct IstanbulAlias {
899 position: IstanbulPosition,
900 primary: bool,
901}
902
903#[derive(Clone, Copy, Debug, Default)]
906struct IstanbulAliasCounts {
907 primary: usize,
908 secondary: usize,
909}
910
911impl IstanbulAliasCounts {
912 fn add(&mut self, alias: IstanbulAlias) {
913 if alias.primary {
914 self.primary += 1;
915 } else {
916 self.secondary += 1;
917 }
918 }
919
920 const fn is_unique(self, alias: IstanbulAlias) -> bool {
923 if alias.primary {
924 self.primary == 1
925 } else {
926 self.primary == 0 && self.secondary == 1
927 }
928 }
929
930 const fn has_secondary_only_collision(self) -> bool {
931 self.primary == 0 && self.secondary > 1
932 }
933}
934
935fn is_anonymous_istanbul_name(name: &str) -> bool {
936 name.starts_with("(anonymous_")
937}
938
939struct IstanbulFunctionCoverage {
940 name: String,
941 coverage_pct: f64,
942 aliases: Vec<IstanbulAlias>,
943 decl_start: IstanbulPosition,
947 header_holds_other_fn: bool,
951 header_span: Option<IstanbulSpan>,
952 body_span: Option<IstanbulSpan>,
953}
954
955impl IstanbulFunctionCoverage {
956 fn answers_to(&self, name: &str) -> bool {
959 if self.name == name {
960 return true;
961 }
962 accessor_property_name(&self.name) == Some(name)
963 }
964
965 fn nearest_alias(
966 &self,
967 target: IstanbulPosition,
968 max_column_drift: Option<u32>,
969 ) -> Option<(u32, u32)> {
970 self.aliases
971 .iter()
972 .filter_map(|alias| {
973 let distance = alias.position.distance_from(target);
974 if distance.0 > ALIAS_FUZZ_MAX_LINE_DRIFT {
975 return None;
976 }
977 if distance.0 > 0 && max_column_drift.is_some_and(|maximum| distance.1 > maximum) {
978 return None;
979 }
980 Some(distance)
981 })
982 .min()
983 }
984}
985
986pub struct IstanbulFileCoverage {
989 functions: Vec<IstanbulFunctionCoverage>,
993 alias_index: rustc_hash::FxHashMap<(String, u32, u32), usize>,
998 ambiguous_aliases: rustc_hash::FxHashSet<(String, u32, u32)>,
1002 ambiguous_anonymous_aliases: rustc_hash::FxHashSet<IstanbulPosition>,
1006 alias_lines: Vec<(u32, usize)>,
1011 header_starts: Vec<(u32, usize)>,
1014 max_header_height: u32,
1018 relocated: bool,
1022}
1023
1024fn mark_headers_holding_other_fns(functions: &mut [IstanbulFunctionCoverage]) {
1030 let mut declarations: Vec<IstanbulPosition> = functions
1031 .iter()
1032 .map(|function| function.decl_start)
1033 .collect();
1034 declarations.sort_unstable();
1035 for function in functions {
1036 let Some(span) = function.header_span else {
1037 continue;
1038 };
1039 let first = declarations.partition_point(|position| *position < span.start);
1042 let past = declarations.partition_point(|position| *position < span.end);
1043 function.header_holds_other_fn = past - first > 1;
1044 }
1045}
1046
1047struct IstanbulLineIndexes {
1051 alias_lines: Vec<(u32, usize)>,
1052 header_starts: Vec<(u32, usize)>,
1053 max_header_height: u32,
1054}
1055
1056impl IstanbulLineIndexes {
1057 fn build(functions: &[IstanbulFunctionCoverage]) -> Self {
1058 let mut alias_lines: Vec<(u32, usize)> = functions
1059 .iter()
1060 .enumerate()
1061 .flat_map(|(function_index, function)| {
1062 function
1063 .aliases
1064 .iter()
1065 .map(move |alias| (alias.position.line, function_index))
1066 })
1067 .collect();
1068 alias_lines.sort_unstable();
1069
1070 let mut header_starts: Vec<(u32, usize)> = Vec::new();
1071 let mut max_header_height = 0;
1072 for (function_index, function) in functions.iter().enumerate() {
1073 if let Some(span) = function.header_span {
1074 header_starts.push((span.start.line, function_index));
1075 max_header_height = max_header_height.max(span.end.line - span.start.line);
1078 }
1079 }
1080 header_starts.sort_unstable();
1081
1082 Self {
1083 alias_lines,
1084 header_starts,
1085 max_header_height,
1086 }
1087 }
1088}
1089
1090impl IstanbulFileCoverage {
1091 pub fn lookup_function(
1097 &self,
1098 function: &fallow_types::extract::FunctionComplexity,
1099 ) -> Option<f64> {
1100 if function.is_private_member {
1101 return None;
1102 }
1103 self.lookup(function.name.as_str(), function.line, function.col)
1104 }
1105
1106 fn new(mut functions: Vec<IstanbulFunctionCoverage>, relocated: bool) -> Self {
1107 let mut named_alias_counts: rustc_hash::FxHashMap<
1108 (String, IstanbulPosition),
1109 IstanbulAliasCounts,
1110 > = rustc_hash::FxHashMap::default();
1111 let mut anonymous_alias_counts: rustc_hash::FxHashMap<
1112 IstanbulPosition,
1113 IstanbulAliasCounts,
1114 > = rustc_hash::FxHashMap::default();
1115 for function in &functions {
1116 let is_anonymous = is_anonymous_istanbul_name(&function.name);
1117 for alias in &function.aliases {
1118 named_alias_counts
1119 .entry((function.name.clone(), alias.position))
1120 .or_default()
1121 .add(*alias);
1122 if is_anonymous {
1123 anonymous_alias_counts
1124 .entry(alias.position)
1125 .or_default()
1126 .add(*alias);
1127 }
1128 }
1129 }
1130
1131 let mut ambiguous_aliases = rustc_hash::FxHashSet::default();
1136 let mut ambiguous_anonymous_aliases = rustc_hash::FxHashSet::default();
1137 for function in &mut functions {
1138 let name = function.name.clone();
1139 let is_anonymous = is_anonymous_istanbul_name(&name);
1140 function.aliases.retain(|alias| {
1141 let named = named_alias_counts
1142 .get(&(name.clone(), alias.position))
1143 .copied()
1144 .unwrap_or_default();
1145 let anonymous = is_anonymous
1146 .then(|| anonymous_alias_counts.get(&alias.position).copied())
1147 .flatten();
1148 let unique = named.is_unique(*alias)
1149 && anonymous.is_none_or(|counts| counts.is_unique(*alias));
1150 if unique {
1151 return true;
1152 }
1153 if alias.primary {
1154 ambiguous_aliases.insert((
1155 name.clone(),
1156 alias.position.line,
1157 alias.position.col,
1158 ));
1159 if anonymous.is_some_and(|counts| counts.primary > 1) {
1160 ambiguous_anonymous_aliases.insert(alias.position);
1161 }
1162 } else {
1163 if named.has_secondary_only_collision() {
1164 ambiguous_aliases.insert((
1165 name.clone(),
1166 alias.position.line,
1167 alias.position.col,
1168 ));
1169 }
1170 if anonymous.is_some_and(IstanbulAliasCounts::has_secondary_only_collision) {
1171 ambiguous_anonymous_aliases.insert(alias.position);
1172 }
1173 }
1174 false
1175 });
1176 }
1177
1178 let mut alias_index = rustc_hash::FxHashMap::default();
1179 for (function_index, function) in functions.iter().enumerate() {
1180 for alias in &function.aliases {
1181 alias_index.insert(
1182 (
1183 function.name.clone(),
1184 alias.position.line,
1185 alias.position.col,
1186 ),
1187 function_index,
1188 );
1189 if let Some(property) = accessor_property_name(&function.name) {
1192 alias_index
1193 .entry((
1194 property.to_string(),
1195 alias.position.line,
1196 alias.position.col,
1197 ))
1198 .or_insert(function_index);
1199 }
1200 }
1201 }
1202
1203 mark_headers_holding_other_fns(&mut functions);
1204 let indexes = IstanbulLineIndexes::build(&functions);
1205
1206 Self {
1207 functions,
1208 alias_index,
1209 ambiguous_aliases,
1210 ambiguous_anonymous_aliases,
1211 alias_lines: indexes.alias_lines,
1212 header_starts: indexes.header_starts,
1213 max_header_height: indexes.max_header_height,
1214 relocated,
1215 }
1216 }
1217
1218 fn fuzz_window(&self, target: IstanbulPosition) -> Vec<usize> {
1230 let low = target.line.saturating_sub(ALIAS_FUZZ_MAX_LINE_DRIFT);
1231 let high = target.line.saturating_add(ALIAS_FUZZ_MAX_LINE_DRIFT);
1232 let first = self.alias_lines.partition_point(|(line, _)| *line < low);
1233 let past = self.alias_lines.partition_point(|(line, _)| *line <= high);
1234 let mut window: Vec<usize> = self.alias_lines[first..past]
1235 .iter()
1236 .map(|(_, function_index)| *function_index)
1237 .collect();
1238 window.sort_unstable();
1239 window.dedup();
1240 window
1241 }
1242
1243 pub(crate) fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
1276 let exact_key = (name.to_string(), line, col);
1277 if self.ambiguous_aliases.contains(&exact_key) {
1278 return None;
1279 }
1280 if let Some(&function_index) = self.alias_index.get(&exact_key) {
1281 return Some(self.functions[function_index].coverage_pct);
1282 }
1283
1284 let target = IstanbulPosition::new(line, col);
1285 let window = self.fuzz_window(target);
1286 let signature_owners = self.header_spans_containing(target);
1292 if let Some(function) = window
1293 .iter()
1294 .copied()
1295 .filter(|function_index| self.functions[*function_index].answers_to(name))
1296 .filter_map(|function_index| {
1297 let function = &self.functions[function_index];
1298 function
1299 .nearest_alias(target, None)
1300 .map(|distance| (distance, function_index))
1301 })
1302 .filter(|(distance, function_index)| {
1303 *distance == (0, 0)
1304 || !self.foreign_signature_blocks(&signature_owners, *function_index, target)
1305 })
1306 .min_by_key(|(distance, _)| *distance)
1307 .map(|(_, function_index)| &self.functions[function_index])
1308 {
1309 return Some(function.coverage_pct);
1310 }
1311 if self.relocated
1312 && let Some(pct) = self.unambiguous_named_pct(name)
1313 {
1314 return Some(pct);
1315 }
1316 if self.ambiguous_anonymous_aliases.contains(&target) {
1317 return self.unique_anonymous_header_match(&signature_owners);
1318 }
1319
1320 let mut nearest_distance: Option<(u32, u32)> = None;
1321 let mut nearest_functions = Vec::new();
1322 for function_index in window {
1323 let function = &self.functions[function_index];
1324 if !is_anonymous_istanbul_name(&function.name) {
1325 continue;
1326 }
1327 let Some(distance) =
1328 function.nearest_alias(target, Some(ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT))
1329 else {
1330 continue;
1331 };
1332 if distance != (0, 0)
1336 && self.foreign_signature_blocks(&signature_owners, function_index, target)
1337 {
1338 continue;
1339 }
1340 match nearest_distance {
1341 None => {
1342 nearest_distance = Some(distance);
1343 nearest_functions.push(function_index);
1344 }
1345 Some(previous) if distance < previous => {
1346 nearest_distance = Some(distance);
1347 nearest_functions.clear();
1348 nearest_functions.push(function_index);
1349 }
1350 Some(previous) if distance == previous => {
1351 nearest_functions.push(function_index);
1352 }
1353 Some(_) => {}
1354 }
1355 }
1356 let established_match = match nearest_functions.as_slice() {
1357 [] => None,
1358 [function_index] => Some(self.functions[*function_index].coverage_pct),
1359 tied => self.innermost_anonymous_match(tied, target),
1360 };
1361 if established_match.is_some() {
1362 return established_match;
1363 }
1364
1365 self.unique_anonymous_header_match(&signature_owners)
1366 }
1367
1368 fn header_spans_containing(&self, target: IstanbulPosition) -> Vec<usize> {
1373 let low = target.line.saturating_sub(self.max_header_height);
1374 let first = self.header_starts.partition_point(|(line, _)| *line < low);
1375 let past = self
1376 .header_starts
1377 .partition_point(|(line, _)| *line <= target.line);
1378 self.header_starts[first..past]
1379 .iter()
1380 .filter(|(_, function_index)| {
1381 self.functions[*function_index]
1382 .header_span
1383 .is_some_and(|span| span.contains(target))
1384 })
1385 .map(|(_, function_index)| *function_index)
1386 .collect()
1387 }
1388
1389 fn foreign_signature_blocks(
1400 &self,
1401 owners: &[usize],
1402 candidate: usize,
1403 target: IstanbulPosition,
1404 ) -> bool {
1405 let function = &self.functions[candidate];
1406 if function
1407 .header_span
1408 .is_some_and(|span| span.contains(target))
1409 || function.body_span.is_some_and(|span| span.contains(target))
1410 {
1411 return false;
1412 }
1413 owners.iter().any(|&owner| {
1414 owner != candidate
1415 && self.functions[owner]
1416 .header_span
1417 .is_some_and(|span| span.contains(function.decl_start))
1418 })
1419 }
1420
1421 fn unique_anonymous_header_match(&self, signature_owners: &[usize]) -> Option<f64> {
1433 let [function_index] = signature_owners else {
1434 return None;
1435 };
1436 let function = &self.functions[*function_index];
1437 if !is_anonymous_istanbul_name(&function.name) || function.header_holds_other_fn {
1438 return None;
1439 }
1440 Some(function.coverage_pct)
1441 }
1442
1443 fn innermost_anonymous_match(&self, tied: &[usize], target: IstanbulPosition) -> Option<f64> {
1444 let containing: Option<Vec<_>> = tied
1445 .iter()
1446 .map(|&function_index| {
1447 self.functions[function_index]
1448 .body_span
1449 .filter(|span| span.contains(target))
1450 .map(|span| (function_index, span))
1451 })
1452 .collect();
1453 let containing = containing?;
1454
1455 let mut winner = None;
1456 for &(function_index, candidate_span) in &containing {
1457 let is_strictly_innermost = containing.iter().all(|&(other_index, other_span)| {
1458 other_index == function_index || other_span.strictly_contains(candidate_span)
1459 });
1460 if !is_strictly_innermost {
1461 continue;
1462 }
1463 if winner.replace(function_index).is_some() {
1464 return None;
1465 }
1466 }
1467 winner.map(|function_index| self.functions[function_index].coverage_pct)
1468 }
1469
1470 fn unambiguous_named_pct(&self, name: &str) -> Option<f64> {
1475 let mut found: Option<f64> = None;
1476 for function in &self.functions {
1477 if !function.answers_to(name) {
1478 continue;
1479 }
1480 match found {
1481 None => found = Some(function.coverage_pct),
1482 Some(previous) if previous.to_bits() == function.coverage_pct.to_bits() => {}
1483 Some(_) => return None,
1484 }
1485 }
1486 found
1487 }
1488}
1489
1490pub struct IstanbulCoverage {
1492 files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
1493}
1494
1495impl IstanbulCoverage {
1496 pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
1498 self.files.get(path)
1499 }
1500
1501 pub fn file_count(&self) -> usize {
1503 self.files.len()
1504 }
1505}
1506
1507enum CrapCoverageResolution<'a> {
1515 TemplateInherited(&'a TemplateInheritContext),
1516 Istanbul {
1517 file_coverage: Option<&'a IstanbulFileCoverage>,
1518 },
1519 StaticEstimated,
1520}
1521
1522fn resolve_crap_coverage<'a>(
1523 template_inherit: Option<&'a TemplateInheritContext>,
1524 istanbul_coverage: Option<&'a IstanbulCoverage>,
1525 path: &std::path::Path,
1526) -> CrapCoverageResolution<'a> {
1527 if let Some(inherit_ctx) = template_inherit {
1528 CrapCoverageResolution::TemplateInherited(inherit_ctx)
1529 } else if let Some(istanbul) = istanbul_coverage {
1530 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1531 CrapCoverageResolution::Istanbul {
1532 file_coverage: istanbul.get(&canonical),
1533 }
1534 } else {
1535 CrapCoverageResolution::StaticEstimated
1536 }
1537}
1538
1539pub fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
1547 let candidates = [
1548 root.join("coverage/coverage-final.json"),
1549 root.join(".nyc_output/coverage-final.json"),
1550 ];
1551 candidates.into_iter().find(|p| p.is_file())
1552}
1553
1554pub fn resolve_relative_to_root(
1560 path: &std::path::Path,
1561 project_root: Option<&std::path::Path>,
1562) -> std::path::PathBuf {
1563 if fallow_types::path_util::is_absolute_path_any_platform(path) {
1564 return path.to_path_buf();
1565 }
1566 match project_root {
1567 Some(root) => root.join(path),
1568 None => path.to_path_buf(),
1569 }
1570}
1571
1572#[cfg(test)]
1587fn load_istanbul_coverage(
1588 path: &std::path::Path,
1589 coverage_root: Option<&std::path::Path>,
1590 project_root: Option<&std::path::Path>,
1591 relocated: bool,
1592) -> Result<IstanbulCoverage, String> {
1593 load_istanbul_coverage_for_sources(path, coverage_root, project_root, None, relocated)
1594}
1595
1596pub(super) fn load_istanbul_coverage_for_sources(
1597 path: &std::path::Path,
1598 coverage_root: Option<&std::path::Path>,
1599 project_root: Option<&std::path::Path>,
1600 discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1601 relocated: bool,
1602) -> Result<IstanbulCoverage, String> {
1603 super::validate_coverage_root_absolute(coverage_root)?;
1604 let resolved = resolve_relative_to_root(path, project_root);
1605 let file_path = if resolved.is_dir() {
1606 let candidate = resolved.join("coverage-final.json");
1607 if candidate.is_file() {
1608 candidate
1609 } else {
1610 return Err(format!(
1611 "no coverage-final.json found in {}",
1612 resolved.display()
1613 ));
1614 }
1615 } else {
1616 resolved
1617 };
1618
1619 let json = std::fs::read_to_string(&file_path)
1620 .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
1621
1622 let raw: std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage> =
1623 parse_coverage_map_tolerantly(&json).map_err(|e| {
1624 format!(
1625 "failed to parse coverage data from {}: {e}",
1626 file_path.display()
1627 )
1628 })?;
1629
1630 let mut files = rustc_hash::FxHashMap::default();
1631 for file_cov in raw.values() {
1632 let raw_path = resolve_relative_to_root(std::path::Path::new(&file_cov.path), project_root);
1637 let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
1638 rebase_coverage_path(raw_path, cov_root, proj_root)
1639 } else {
1640 raw_path
1641 };
1642 let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
1643 let source = read_discovered_source(&canonical, discovered_sources, relocated);
1644 let source_index = source
1645 .as_deref()
1646 .map(|source| IstanbulSourceIndex::new(source, &canonical));
1647
1648 let statement_owners = tally_statements_by_owner(file_cov);
1649 let mut functions = Vec::with_capacity(file_cov.fn_map.len());
1650 for (fn_id, fn_entry) in &file_cov.fn_map {
1651 let coverage_pct = compute_function_statement_coverage(
1652 file_cov,
1653 fn_id,
1654 statement_owners.get(fn_id.as_str()),
1655 );
1656 if let Some(function) =
1657 istanbul_function_coverage(fn_entry, coverage_pct, source_index.as_ref())
1658 {
1659 functions.push(function);
1660 }
1661 }
1662
1663 files.insert(canonical, IstanbulFileCoverage::new(functions, relocated));
1664 }
1665
1666 Ok(IstanbulCoverage { files })
1667}
1668
1669#[expect(
1670 clippy::filetype_is_file,
1671 reason = "coverage provenance must admit regular files and reject every special file type"
1672)]
1673fn read_discovered_source(
1674 path: &std::path::Path,
1675 discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1676 relocated: bool,
1677) -> Option<String> {
1678 if relocated || !discovered_sources.is_some_and(|sources| sources.contains(path)) {
1679 return None;
1680 }
1681 if std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
1682 std::fs::read_to_string(path).ok()
1683 } else {
1684 None
1685 }
1686}
1687
1688fn parse_coverage_map_tolerantly(
1697 json: &str,
1698) -> Result<std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>, String> {
1699 match oxc_coverage_instrument::parse_coverage_map(json) {
1700 Ok(raw) => Ok(raw),
1701 Err(strict_error) => {
1702 let mut value: serde_json::Value =
1703 serde_json::from_str(json).map_err(|_| strict_error.to_string())?;
1704 if !clamp_negative_positions(&mut value) {
1705 return Err(strict_error.to_string());
1706 }
1707 serde_json::from_value(value).map_err(|_| strict_error.to_string())
1708 }
1709 }
1710}
1711
1712fn clamp_negative_positions(value: &mut serde_json::Value) -> bool {
1716 match value {
1717 serde_json::Value::Object(entries) => {
1718 let mut clamped = false;
1719 for (key, child) in entries.iter_mut() {
1720 if matches!(key.as_str(), "line" | "column")
1721 && child.as_i64().is_some_and(|number| number < 0)
1722 {
1723 *child = serde_json::Value::from(0);
1724 clamped = true;
1725 continue;
1726 }
1727 clamped |= clamp_negative_positions(child);
1728 }
1729 clamped
1730 }
1731 serde_json::Value::Array(items) => items.iter_mut().fold(false, |clamped, item| {
1732 clamped | clamp_negative_positions(item)
1733 }),
1734 _ => false,
1735 }
1736}
1737
1738fn accessor_property_name(name: &str) -> Option<&str> {
1746 let property = name
1747 .strip_prefix("get ")
1748 .or_else(|| name.strip_prefix("set "))?;
1749 (!property.is_empty() && !property.contains(' ')).then_some(property)
1750}
1751
1752fn rebase_coverage_path(
1760 raw_path: std::path::PathBuf,
1761 coverage_root: &std::path::Path,
1762 project_root: &std::path::Path,
1763) -> std::path::PathBuf {
1764 if let Ok(rel) = raw_path.strip_prefix(coverage_root) {
1765 return project_root.join(rel);
1766 }
1767 if let Ok(canonical) = dunce::canonicalize(&raw_path)
1768 && let Ok(rel) = canonical.strip_prefix(coverage_root)
1769 {
1770 return project_root.join(rel);
1771 }
1772 raw_path
1773}
1774
1775fn istanbul_function_coverage(
1776 fn_entry: &oxc_coverage_instrument::FnEntry,
1777 coverage_pct: f64,
1778 source_index: Option<&IstanbulSourceIndex<'_>>,
1779) -> Option<IstanbulFunctionCoverage> {
1780 let body_span = IstanbulSpan::from_entry(fn_entry, source_index);
1781 let header_span = IstanbulSpan::header_from_entry(fn_entry, source_index);
1782 let decl_start = normalized_istanbul_position(
1783 fn_entry.decl.start.line,
1784 fn_entry.decl.start.column,
1785 source_index,
1786 )?;
1787 let effective_position = normalized_istanbul_position(
1788 effective_istanbul_fn_line(fn_entry),
1789 fn_entry.decl.start.column,
1790 source_index,
1791 );
1792 let candidates = [
1793 effective_position.map(|position| IstanbulAlias {
1794 position,
1795 primary: true,
1796 }),
1797 Some(IstanbulAlias {
1798 position: decl_start,
1799 primary: true,
1800 }),
1801 body_span.map(|span| IstanbulAlias {
1802 position: span.start,
1803 primary: false,
1804 }),
1805 named_function_syntax_alias(fn_entry, source_index),
1806 ];
1807 let mut aliases: Vec<IstanbulAlias> = Vec::with_capacity(candidates.len());
1808 for candidate in candidates.into_iter().flatten() {
1809 if !aliases
1810 .iter()
1811 .any(|alias| alias.position == candidate.position)
1812 {
1813 aliases.push(candidate);
1814 }
1815 }
1816
1817 Some(IstanbulFunctionCoverage {
1818 name: fn_entry.name.clone(),
1819 coverage_pct,
1820 aliases,
1821 decl_start,
1822 header_holds_other_fn: false,
1823 header_span,
1824 body_span,
1825 })
1826}
1827
1828struct IstanbulSourceIndex<'a> {
1831 source: &'a str,
1832 line_starts: Vec<usize>,
1833 non_ascii_lines: rustc_hash::FxHashMap<usize, Utf16LineIndex>,
1834 named_function_starts: rustc_hash::FxHashMap<usize, usize>,
1835}
1836
1837struct Utf16LineIndex {
1838 utf16_len: u32,
1839 byte_len: usize,
1840 checkpoints: Vec<Utf16Checkpoint>,
1841}
1842
1843struct Utf16Checkpoint {
1844 utf16_start: u32,
1845 utf16_end: u32,
1846 byte_end: usize,
1847}
1848
1849impl<'a> IstanbulSourceIndex<'a> {
1850 fn new(source: &'a str, path: &std::path::Path) -> Self {
1851 let mut line_starts = vec![0];
1852 line_starts.extend(
1853 source
1854 .bytes()
1855 .enumerate()
1856 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
1857 );
1858 let non_ascii_lines = utf16_line_indexes(source, &line_starts);
1859
1860 let named_function_starts = named_function_starts_from_clean_parse(source, path);
1861
1862 Self {
1863 source,
1864 line_starts,
1865 non_ascii_lines,
1866 named_function_starts,
1867 }
1868 }
1869
1870 fn byte_position(&self, line: u32, utf16_column: u32) -> Option<IstanbulPosition> {
1871 let line_index = usize::try_from(line.checked_sub(1)?).ok()?;
1872 let line_start = *self.line_starts.get(line_index)?;
1873 let line_end = self
1874 .line_starts
1875 .get(line_index + 1)
1876 .copied()
1877 .map_or(self.source.len(), |next_start| next_start - 1);
1878 let line_source = self.source.get(line_start..line_end)?;
1879 let byte_column = if let Some(index) = self.non_ascii_lines.get(&line_index) {
1880 index.byte_column(utf16_column)?
1881 } else {
1882 let byte_column = usize::try_from(utf16_column).ok()?;
1883 (byte_column <= line_source.len()).then_some(byte_column)?
1884 };
1885 Some(IstanbulPosition::new(
1886 line,
1887 u32::try_from(byte_column).ok()?,
1888 ))
1889 }
1890
1891 fn absolute_offset(&self, line: u32, utf16_column: u32) -> Option<usize> {
1892 let position = self.byte_position(line, utf16_column)?;
1893 let line_index = usize::try_from(position.line.checked_sub(1)?).ok()?;
1894 self.line_starts
1895 .get(line_index)?
1896 .checked_add(position.col as usize)
1897 }
1898
1899 fn position_at_offset(&self, offset: usize) -> Option<IstanbulPosition> {
1900 if offset > self.source.len() {
1901 return None;
1902 }
1903 let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
1904 Some(IstanbulPosition::new(
1905 u32::try_from(line_index + 1).ok()?,
1906 u32::try_from(offset.checked_sub(self.line_starts[line_index])?).ok()?,
1907 ))
1908 }
1909
1910 fn named_function_start(
1911 &self,
1912 fn_entry: &oxc_coverage_instrument::FnEntry,
1913 ) -> Option<IstanbulPosition> {
1914 let declaration_offset =
1915 self.absolute_offset(fn_entry.decl.start.line, fn_entry.decl.start.column)?;
1916 let syntax_offset = *self.named_function_starts.get(&declaration_offset)?;
1917 self.position_at_offset(syntax_offset)
1918 }
1919}
1920
1921impl Utf16LineIndex {
1922 fn byte_column(&self, utf16_column: u32) -> Option<usize> {
1923 if utf16_column > self.utf16_len {
1924 return None;
1925 }
1926 let completed = self
1927 .checkpoints
1928 .partition_point(|checkpoint| checkpoint.utf16_end <= utf16_column);
1929 if let Some(next) = self.checkpoints.get(completed)
1930 && utf16_column > next.utf16_start
1931 {
1932 return None;
1933 }
1934 let (utf16_base, byte_base) = completed
1935 .checked_sub(1)
1936 .and_then(|index| self.checkpoints.get(index))
1937 .map_or((0, 0), |checkpoint| {
1938 (checkpoint.utf16_end, checkpoint.byte_end)
1939 });
1940 let ascii_width = usize::try_from(utf16_column.checked_sub(utf16_base)?).ok()?;
1941 let byte_column = byte_base.checked_add(ascii_width)?;
1942 (byte_column <= self.byte_len).then_some(byte_column)
1943 }
1944}
1945
1946fn utf16_line_indexes(
1947 source: &str,
1948 line_starts: &[usize],
1949) -> rustc_hash::FxHashMap<usize, Utf16LineIndex> {
1950 let mut indexes = rustc_hash::FxHashMap::default();
1951 for (line_index, &line_start) in line_starts.iter().enumerate() {
1952 let line_end = line_starts
1953 .get(line_index + 1)
1954 .copied()
1955 .map_or(source.len(), |next_start| next_start - 1);
1956 let Some(line) = source.get(line_start..line_end) else {
1957 continue;
1958 };
1959 if line.is_ascii() {
1960 continue;
1961 }
1962 let mut utf16_column = 0_u32;
1963 let mut checkpoints = Vec::new();
1964 for (byte_column, character) in line.char_indices() {
1965 let utf16_width = character.len_utf16() as u32;
1966 if !character.is_ascii() {
1967 checkpoints.push(Utf16Checkpoint {
1968 utf16_start: utf16_column,
1969 utf16_end: utf16_column.saturating_add(utf16_width),
1970 byte_end: byte_column.saturating_add(character.len_utf8()),
1971 });
1972 }
1973 utf16_column = utf16_column.saturating_add(utf16_width);
1974 }
1975 indexes.insert(
1976 line_index,
1977 Utf16LineIndex {
1978 utf16_len: utf16_column,
1979 byte_len: line.len(),
1980 checkpoints,
1981 },
1982 );
1983 }
1984 indexes
1985}
1986
1987fn named_function_starts_from_clean_parse(
1988 source: &str,
1989 path: &std::path::Path,
1990) -> rustc_hash::FxHashMap<usize, usize> {
1991 let source_type = match path.extension().and_then(|extension| extension.to_str()) {
1992 Some("gts") => oxc_span::SourceType::ts(),
1993 Some("gjs") => oxc_span::SourceType::mjs(),
1994 _ => oxc_span::SourceType::from_path(path).unwrap_or_default(),
1995 };
1996 if let Some(starts) = collect_named_function_starts(source, source_type) {
1997 return starts;
1998 }
1999 if source_type.is_jsx() {
2000 return rustc_hash::FxHashMap::default();
2001 }
2002 let jsx_source_type = if source_type.is_typescript() {
2003 oxc_span::SourceType::tsx()
2004 } else {
2005 oxc_span::SourceType::jsx()
2006 };
2007 collect_named_function_starts(source, jsx_source_type).unwrap_or_default()
2008}
2009
2010fn collect_named_function_starts(
2011 source: &str,
2012 source_type: oxc_span::SourceType,
2013) -> Option<rustc_hash::FxHashMap<usize, usize>> {
2014 let allocator = oxc_allocator::Allocator::default();
2015 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
2016 if parsed.panicked || !parsed.errors.is_empty() {
2017 return None;
2018 }
2019 let mut starts = rustc_hash::FxHashMap::default();
2020 let mut collector = NamedFunctionSyntaxCollector {
2021 starts: &mut starts,
2022 };
2023 oxc_ast_visit::Visit::visit_program(&mut collector, &parsed.program);
2024 Some(starts)
2025}
2026
2027struct NamedFunctionSyntaxCollector<'a> {
2028 starts: &'a mut rustc_hash::FxHashMap<usize, usize>,
2029}
2030
2031impl<'ast> oxc_ast_visit::Visit<'ast> for NamedFunctionSyntaxCollector<'_> {
2032 fn visit_function(
2033 &mut self,
2034 function: &oxc_ast::ast::Function<'ast>,
2035 flags: oxc_syntax::scope::ScopeFlags,
2036 ) {
2037 if let Some(identifier) = &function.id
2038 && let (Ok(identifier_start), Ok(syntax_start)) = (
2039 usize::try_from(identifier.span.start),
2040 usize::try_from(function.span.start),
2041 )
2042 {
2043 self.starts.insert(identifier_start, syntax_start);
2044 }
2045 oxc_ast_visit::walk::walk_function(self, function, flags);
2046 }
2047}
2048
2049fn normalized_istanbul_position(
2050 line: u32,
2051 column: u32,
2052 source_index: Option<&IstanbulSourceIndex<'_>>,
2053) -> Option<IstanbulPosition> {
2054 match source_index {
2055 Some(index) => index.byte_position(line, column),
2056 None => Some(IstanbulPosition::new(line, column)),
2057 }
2058}
2059
2060fn named_function_syntax_alias(
2066 fn_entry: &oxc_coverage_instrument::FnEntry,
2067 source_index: Option<&IstanbulSourceIndex<'_>>,
2068) -> Option<IstanbulAlias> {
2069 if is_anonymous_istanbul_name(&fn_entry.name) {
2070 return None;
2071 }
2072 Some(IstanbulAlias {
2073 position: source_index?.named_function_start(fn_entry)?,
2074 primary: true,
2075 })
2076}
2077
2078fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
2079 if fn_entry.line > 0 {
2080 fn_entry.line
2081 } else {
2082 fn_entry.decl.start.line
2083 }
2084}
2085
2086#[derive(Default)]
2088struct StatementTally {
2089 covered: u32,
2090 total: u32,
2091}
2092
2093fn istanbul_range_contains(
2099 outer: &oxc_coverage_instrument::Location,
2100 inner: &oxc_coverage_instrument::Location,
2101) -> bool {
2102 let after_start = inner.start.line > outer.start.line
2103 || (inner.start.line == outer.start.line && inner.start.column >= outer.start.column);
2104 let before_end = inner.end.line < outer.end.line
2105 || (inner.end.line == outer.end.line && inner.end.column <= outer.end.column);
2106 after_start && before_end
2107}
2108
2109fn tally_statements_by_owner(
2129 file_cov: &oxc_coverage_instrument::FileCoverage,
2130) -> rustc_hash::FxHashMap<&str, StatementTally> {
2131 let mut owners: rustc_hash::FxHashMap<&str, StatementTally> = rustc_hash::FxHashMap::default();
2132
2133 for (stmt_id, stmt_loc) in &file_cov.statement_map {
2134 let mut owner: Option<(&str, &oxc_coverage_instrument::Location)> = None;
2135
2136 for (fn_id, fn_entry) in &file_cov.fn_map {
2137 if !istanbul_range_contains(&fn_entry.loc, stmt_loc) {
2138 continue;
2139 }
2140 let inner_than_current = owner.is_none_or(|(_, best)| {
2141 let candidate = (
2142 (fn_entry.loc.start.line, fn_entry.loc.start.column),
2143 std::cmp::Reverse((fn_entry.loc.end.line, fn_entry.loc.end.column)),
2144 );
2145 let incumbent = (
2146 (best.start.line, best.start.column),
2147 std::cmp::Reverse((best.end.line, best.end.column)),
2148 );
2149 candidate > incumbent
2150 });
2151 if inner_than_current {
2152 owner = Some((fn_id.as_str(), &fn_entry.loc));
2153 }
2154 }
2155
2156 let Some((fn_id, _)) = owner else {
2157 continue;
2158 };
2159 let tally = owners.entry(fn_id).or_default();
2160 tally.total += 1;
2161 if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
2162 tally.covered += 1;
2163 }
2164 }
2165
2166 owners
2167}
2168
2169fn compute_function_statement_coverage(
2178 file_cov: &oxc_coverage_instrument::FileCoverage,
2179 fn_id: &str,
2180 owned: Option<&StatementTally>,
2181) -> f64 {
2182 match owned {
2183 Some(tally) if tally.total > 0 => f64::from(tally.covered) / f64::from(tally.total) * 100.0,
2184 _ => {
2185 let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
2186 if hit > 0 { 100.0 } else { 0.0 }
2187 }
2188 }
2189}
2190
2191fn count_unused_exports_by_path(
2196 unused_exports: &[crate::results::UnusedExportFinding],
2197) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
2198 let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
2199 for exp in unused_exports {
2200 *map.entry(exp.export.path.as_path()).or_default() += 1;
2201 }
2202 map
2203}
2204
2205fn compute_maintainability_index(
2225 complexity_density: f64,
2226 dead_code_ratio: f64,
2227 fan_out: usize,
2228 lines: u32,
2229) -> f64 {
2230 let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
2231 let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
2232 #[expect(
2233 clippy::suboptimal_flops,
2234 reason = "formula matches documented specification"
2235 )]
2236 let score = 100.0
2237 - (complexity_density * 30.0 * dampening)
2238 - (dead_code_ratio * 20.0)
2239 - fan_out_penalty;
2240 score.clamp(0.0, 100.0)
2241}
2242
2243fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
2244 (100.0 - score.maintainability_index).clamp(0.0, 100.0)
2245}
2246
2247#[must_use]
2253pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
2254 max_crap_threshold <= 0.0 || (score.crap_above_threshold == 0 && score.crap_exempted > 0)
2255}
2256
2257fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2264 if file_score_fully_crap_exempt(score, max_crap_threshold) {
2265 return 0.0;
2266 }
2267 let crap_max = score.crap_max;
2268 let t = score.crap_effective_threshold.unwrap_or(max_crap_threshold);
2269 let half = t / 2.0;
2270 let saturation = t * 10.0 / 3.0;
2271 if crap_max <= 0.0 {
2272 0.0
2273 } else if crap_max < half {
2274 (crap_max / half) * 45.0
2275 } else if crap_max < t {
2276 ((crap_max - half) / half).mul_add(30.0, 45.0)
2277 } else if crap_max < saturation {
2278 ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
2279 } else {
2280 100.0
2281 }
2282}
2283
2284fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2285 file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
2286}
2287
2288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2294pub enum FileScoreConcern {
2295 Structural,
2297 Risk,
2299}
2300
2301impl FileScoreConcern {
2302 pub const fn label(self) -> &'static str {
2304 match self {
2305 Self::Structural => "structure",
2306 Self::Risk => "risk",
2307 }
2308 }
2309}
2310
2311pub fn file_score_concern_axis(
2320 score: &FileHealthScore,
2321 max_crap_threshold: f64,
2322) -> FileScoreConcern {
2323 let crap_concern = file_score_crap_concern(score, max_crap_threshold);
2324 if crap_concern <= 0.0 {
2325 FileScoreConcern::Structural
2326 } else if crap_concern >= file_score_structural_concern(score) {
2327 FileScoreConcern::Risk
2328 } else {
2329 FileScoreConcern::Structural
2330 }
2331}
2332
2333fn compare_file_score_triage(
2334 a: &FileHealthScore,
2335 b: &FileHealthScore,
2336 max_crap_threshold: f64,
2337) -> std::cmp::Ordering {
2338 file_score_triage_concern(b, max_crap_threshold)
2339 .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
2340 .then_with(|| b.crap_max.total_cmp(&a.crap_max))
2341 .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
2342 .then_with(|| a.path.cmp(&b.path))
2343}
2344
2345#[derive(Clone, Copy)]
2348pub(super) struct FileScoreComputeInput<'a> {
2349 pub(super) modules: &'a [crate::source::ModuleInfo],
2350 pub(super) file_paths:
2351 &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2352 pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2353 pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
2354 pub(super) root: &'a std::path::Path,
2355 pub(super) crap_thresholds: CrapScoreThresholds<'a>,
2356}
2357
2358pub(super) fn compute_file_scores(
2364 input: FileScoreComputeInput<'_>,
2365 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2366) -> Result<FileScoreOutput, String> {
2367 let FileScoreComputeInput {
2368 modules,
2369 file_paths,
2370 changed_files,
2371 istanbul_coverage,
2372 root,
2373 crap_thresholds,
2374 } = input;
2375 let retained_graph = analysis_output.graph.ok_or("graph not available")?;
2376 let test_coverage = retained_graph.static_test_coverage();
2377 let graph = retained_graph.as_graph();
2378 let results = &analysis_output.results;
2379
2380 let circular_files = collect_circular_files(results);
2381 let top_complex_fns = collect_top_complex_fns(modules, file_paths);
2382 let cycle_members = collect_cycle_members(results);
2383 let direct_callers = collect_direct_callers(graph, file_paths);
2384 let unused_export_names = collect_unused_export_names(results);
2385
2386 let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
2387 .unused_files
2388 .iter()
2389 .map(|f| f.file.path.as_path())
2390 .collect();
2391
2392 let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
2393
2394 let FileScoreCoverageSetup {
2395 module_by_id,
2396 coverage,
2397 } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
2398
2399 let template_inherit =
2400 build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
2401
2402 let mut acc = accumulate_file_scores(
2403 unused_export_names,
2404 &FileScoreLoopCtx {
2405 graph,
2406 test_coverage,
2407 file_paths,
2408 module_by_id: &module_by_id,
2409 unused_files: &unused_files,
2410 unused_exports_by_path: &unused_exports_by_path,
2411 template_inherit: &template_inherit,
2412 istanbul_coverage,
2413 root,
2414 crap_thresholds,
2415 },
2416 );
2417 acc.scores = finalize_file_score_list(
2418 acc.scores,
2419 changed_files,
2420 crap_thresholds.resolver.global.crap,
2421 );
2422
2423 Ok(build_file_score_output(FileScoreOutputParts {
2424 graph,
2425 file_paths,
2426 results,
2427 scores: acc.scores,
2428 coverage,
2429 circular_files,
2430 top_complex_fns,
2431 entry_points: acc.entry_points,
2432 value_export_counts: acc.value_export_counts,
2433 unused_export_names: acc.unused_export_names,
2434 cycle_members,
2435 direct_callers,
2436 istanbul_matched: acc.istanbul_matched,
2437 istanbul_total: acc.istanbul_total,
2438 istanbul_files_joined: acc.istanbul_files_joined,
2439 istanbul_files_total: acc.istanbul_files_total,
2440 per_function_crap: acc.per_function_crap,
2441 template_inherit,
2442 }))
2443}
2444
2445struct FileScoreLoopCtx<'a> {
2447 graph: &'a fallow_graph::graph::ModuleGraph,
2448 test_coverage: StaticTestCoverage<'a>,
2449 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2450 module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2451 unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
2452 unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
2453 template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2454 istanbul_coverage: Option<&'a IstanbulCoverage>,
2455 root: &'a std::path::Path,
2458 crap_thresholds: CrapScoreThresholds<'a>,
2459}
2460
2461struct FileScoreAccumulator {
2463 scores: Vec<FileHealthScore>,
2464 entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
2465 value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
2466 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2467 per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2468 istanbul_matched: usize,
2469 istanbul_files_joined: usize,
2470 istanbul_files_total: usize,
2471 istanbul_total: usize,
2472}
2473
2474impl FileScoreAccumulator {
2475 fn with_capacity(modules: usize) -> Self {
2477 FileScoreAccumulator {
2478 scores: Vec::with_capacity(modules),
2479 entry_points: rustc_hash::FxHashSet::default(),
2480 value_export_counts: rustc_hash::FxHashMap::default(),
2481 unused_export_names: rustc_hash::FxHashMap::default(),
2482 per_function_crap: rustc_hash::FxHashMap::default(),
2483 istanbul_matched: 0,
2484 istanbul_total: 0,
2485 istanbul_files_joined: 0,
2486 istanbul_files_total: 0,
2487 }
2488 }
2489}
2490
2491fn accumulate_file_scores(
2494 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2495 ctx: &FileScoreLoopCtx<'_>,
2496) -> FileScoreAccumulator {
2497 let mut acc = FileScoreAccumulator {
2498 unused_export_names,
2499 istanbul_files_total: ctx
2500 .istanbul_coverage
2501 .map_or(0, IstanbulCoverage::file_count),
2502 ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
2503 };
2504 for node in &ctx.graph.modules {
2505 let Some(path) = ctx.file_paths.get(&node.file_id) else {
2506 continue;
2507 };
2508 record_entry_point(&mut acc.entry_points, node, path);
2509 let score = compute_one_file_score(&mut acc, ctx, node, path);
2510 acc.scores.push(score);
2511 }
2512 acc
2513}
2514
2515fn finalize_file_score_list(
2518 mut scores: Vec<FileHealthScore>,
2519 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2520 max_crap_threshold: f64,
2521) -> Vec<FileHealthScore> {
2522 if let Some(changed) = changed_files {
2523 scores.retain(|s| changed.contains(&s.path));
2524 }
2525 scores.retain(|s| s.function_count > 0);
2526 scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
2527 scores
2528}
2529
2530fn compute_one_file_score(
2532 acc: &mut FileScoreAccumulator,
2533 ctx: &FileScoreLoopCtx<'_>,
2534 node: &fallow_graph::graph::ModuleNode,
2535 path: &std::path::Path,
2536) -> FileHealthScore {
2537 let fan_in = ctx
2538 .graph
2539 .reverse_deps
2540 .get(node.file_id.0 as usize)
2541 .map_or(0, Vec::len);
2542 let fan_out = node.edge_range.len();
2543
2544 let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
2545 .module_by_id
2546 .get(&node.file_id)
2547 .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
2548
2549 let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
2550 let path_owned = path.to_path_buf();
2551 acc.value_export_counts
2552 .insert(path_owned.clone(), value_exports);
2553 record_unused_file_export_names(
2554 path_owned.as_path(),
2555 &node.exports,
2556 ctx.unused_files,
2557 &mut acc.unused_export_names,
2558 );
2559
2560 let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
2561 compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
2562
2563 let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
2564 let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative);
2565 let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
2566 acc.istanbul_matched += crap.istanbul_matched;
2567 acc.istanbul_total += crap.istanbul_total;
2568 acc.istanbul_files_joined += usize::from(crap.coverage_file_joined);
2569 record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
2570
2571 let global_crap = ctx.crap_thresholds.resolver.global.crap;
2576 let crap_effective_threshold = crap
2577 .signals
2578 .min_ceiling
2579 .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
2580
2581 FileHealthScore {
2582 path: path_owned,
2583 fan_in,
2584 fan_out,
2585 dead_code_ratio: dead_code_ratio_rounded,
2586 complexity_density: complexity_density_rounded,
2587 maintainability_index: maintainability_index_rounded,
2588 total_cyclomatic,
2589 total_cognitive,
2590 function_count,
2591 lines,
2592 crap_max: crap.max,
2593 crap_above_threshold: crap.signals.above,
2594 crap_exempted: crap.signals.exempted,
2595 crap_effective_threshold,
2596 }
2597}
2598
2599fn compute_file_score_metrics(
2602 node: &fallow_graph::graph::ModuleNode,
2603 path: &std::path::Path,
2604 ctx: &FileScoreLoopCtx<'_>,
2605 total_cyclomatic: u32,
2606 lines: u32,
2607 fan_out: usize,
2608) -> (f64, f64, f64) {
2609 let dead_code_ratio = compute_dead_code_ratio(
2610 path,
2611 &node.exports,
2612 ctx.unused_files,
2613 ctx.unused_exports_by_path,
2614 );
2615 let complexity_density = compute_complexity_density(total_cyclomatic, lines);
2616
2617 let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
2618 let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
2619
2620 let maintainability_index = compute_maintainability_index(
2621 complexity_density_rounded,
2622 dead_code_ratio_rounded,
2623 fan_out,
2624 lines,
2625 );
2626 (
2627 dead_code_ratio_rounded,
2628 complexity_density_rounded,
2629 (maintainability_index * 10.0).round() / 10.0,
2630 )
2631}
2632
2633fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
2634 let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
2635 let unused_deps = parts.results.unused_dependencies.len()
2636 + parts.results.unused_dev_dependencies.len()
2637 + parts.results.unused_optional_dependencies.len();
2638 let analysis_snapshot =
2639 build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
2640 let analysis_counts =
2641 build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
2642 let template_inherit_provenance =
2643 build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
2644
2645 FileScoreOutput {
2646 scores: parts.scores,
2647 coverage: parts.coverage,
2648 circular_files: parts.circular_files,
2649 top_complex_fns: parts.top_complex_fns,
2650 entry_points: parts.entry_points,
2651 value_export_counts: parts.value_export_counts,
2652 unused_export_names: parts.unused_export_names,
2653 cycle_members: parts.cycle_members,
2654 direct_callers: parts.direct_callers,
2655 analysis_counts,
2656 prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
2657 render_fan_in: parts.results.render_fan_in.clone(),
2658 analysis_snapshot,
2659 istanbul_matched: parts.istanbul_matched,
2660 istanbul_total: parts.istanbul_total,
2661 istanbul_files_joined: parts.istanbul_files_joined,
2662 istanbul_files_total: parts.istanbul_files_total,
2663 per_function_crap: parts.per_function_crap,
2664 template_inherit_provenance,
2665 }
2666}
2667
2668fn build_file_score_analysis_counts(
2669 results: &crate::results::AnalysisResults,
2670 total_exports: usize,
2671 unused_deps: usize,
2672) -> crate::vital_signs::AnalysisCounts {
2673 crate::vital_signs::AnalysisCounts {
2674 total_exports,
2675 dead_files: results.unused_files.len(),
2676 dead_exports: results.unused_exports.len() + results.unused_types.len(),
2677 unused_deps,
2678 circular_deps: results.circular_dependencies.len(),
2679 total_deps: 0usize,
2680 }
2681}
2682
2683fn build_template_inherit_provenance(
2684 template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2685 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2686) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
2687 template_inherit
2688 .into_iter()
2689 .filter_map(|(file_id, ctx)| {
2690 file_paths
2691 .get(&file_id)
2692 .map(|path| ((**path).clone(), ctx.provenance_owner))
2693 })
2694 .collect()
2695}
2696
2697fn record_entry_point(
2698 entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
2699 node: &fallow_graph::graph::ModuleNode,
2700 path: &std::path::Path,
2701) {
2702 if node.is_entry_point() {
2703 entry_points.insert(path.to_path_buf());
2704 }
2705}
2706
2707fn record_unused_file_export_names(
2708 path: &std::path::Path,
2709 exports: &[fallow_graph::graph::ExportSymbol],
2710 unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
2711 unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2712) {
2713 if !unused_files.contains(path) || unused_export_names.contains_key(path) {
2714 return;
2715 }
2716
2717 let names: Vec<String> = exports
2718 .iter()
2719 .filter(|export| !export.is_type_only)
2720 .map(|export| export.name.to_string())
2721 .collect();
2722 if !names.is_empty() {
2723 unused_export_names.insert(path.to_path_buf(), names);
2724 }
2725}
2726
2727struct FileScoreCrap {
2728 max: f64,
2729 signals: CrapThresholdSignals,
2730 per_function: Vec<PerFunctionCrap>,
2731 istanbul_matched: usize,
2732 istanbul_total: usize,
2733 coverage_file_joined: bool,
2736}
2737
2738impl FileScoreCrap {
2739 fn empty() -> Self {
2740 Self {
2741 max: 0.0,
2742 signals: CrapThresholdSignals::default(),
2743 per_function: Vec::new(),
2744 istanbul_matched: 0,
2745 istanbul_total: 0,
2746 coverage_file_joined: false,
2747 }
2748 }
2749
2750 fn estimated(result: EstimatedCrapResult) -> Self {
2751 Self {
2752 max: result.max_crap,
2753 signals: result.signals,
2754 per_function: result.per_function,
2755 istanbul_matched: 0,
2756 istanbul_total: 0,
2757 coverage_file_joined: false,
2758 }
2759 }
2760
2761 fn istanbul(result: IstanbulCrapResult, coverage_file_joined: bool) -> Self {
2762 Self {
2763 max: result.max_crap,
2764 signals: result.signals,
2765 per_function: result.per_function,
2766 istanbul_matched: result.matched,
2767 istanbul_total: result.total,
2768 coverage_file_joined,
2769 }
2770 }
2771}
2772
2773fn compute_file_score_crap(
2774 node: &fallow_graph::graph::ModuleNode,
2775 ctx: &FileScoreLoopCtx<'_>,
2776 path: &std::path::Path,
2777 ceilings: &CrapCeilingLookup<'_>,
2778) -> FileScoreCrap {
2779 let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
2780 return FileScoreCrap::empty();
2781 };
2782
2783 let is_coverage_suppressed = crate::suppress::is_file_suppressed(
2784 &module.suppressions,
2785 fallow_types::suppress::IssueKind::CoverageGaps,
2786 );
2787 let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
2788 let resolution = resolve_crap_coverage(
2789 ctx.template_inherit.get(&node.file_id),
2790 ctx.istanbul_coverage,
2791 path,
2792 );
2793 match resolution {
2794 CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
2795 compute_template_inherited_crap(module, inherit_ctx, ceilings)
2796 }
2797 CrapCoverageResolution::Istanbul { file_coverage } => {
2798 compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
2799 }
2800 CrapCoverageResolution::StaticEstimated => compute_static_file_crap(
2801 module,
2802 &node.exports,
2803 ctx.test_coverage,
2804 is_test_reachable,
2805 ceilings,
2806 ),
2807 }
2808}
2809
2810fn compute_template_inherited_crap(
2811 module: &crate::source::ModuleInfo,
2812 inherit_ctx: &TemplateInheritContext,
2813 ceilings: &CrapCeilingLookup<'_>,
2814) -> FileScoreCrap {
2815 FileScoreCrap::estimated(compute_crap_scores_estimated(
2816 &module.complexity,
2817 &inherit_ctx.test_referenced_exports,
2818 inherit_ctx.is_test_reachable,
2819 fallow_output::CoverageSource::EstimatedComponentInherited,
2820 ceilings,
2821 ))
2822}
2823
2824fn compute_istanbul_file_crap(
2825 module: &crate::source::ModuleInfo,
2826 file_coverage: Option<&IstanbulFileCoverage>,
2827 is_test_reachable: bool,
2828 ceilings: &CrapCeilingLookup<'_>,
2829) -> FileScoreCrap {
2830 FileScoreCrap::istanbul(
2831 compute_crap_scores_istanbul(
2832 &module.complexity,
2833 file_coverage,
2834 is_test_reachable,
2835 ceilings,
2836 ),
2837 file_coverage.is_some(),
2838 )
2839}
2840
2841fn compute_static_file_crap(
2842 module: &crate::source::ModuleInfo,
2843 exports: &[fallow_graph::graph::ExportSymbol],
2844 test_coverage: StaticTestCoverage<'_>,
2845 is_test_reachable: bool,
2846 ceilings: &CrapCeilingLookup<'_>,
2847) -> FileScoreCrap {
2848 let test_refs = build_test_referenced_exports(exports, test_coverage);
2849 FileScoreCrap::estimated(compute_crap_scores_estimated(
2850 &module.complexity,
2851 &test_refs,
2852 is_test_reachable,
2853 fallow_output::CoverageSource::Estimated,
2854 ceilings,
2855 ))
2856}
2857
2858fn record_per_function_crap(
2859 per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2860 path: &std::path::Path,
2861 per_function: Vec<PerFunctionCrap>,
2862) {
2863 if !per_function.is_empty() {
2864 per_function_crap.insert(path.to_path_buf(), per_function);
2865 }
2866}
2867
2868struct FileScoreCoverageSetup<'a> {
2869 module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2870 coverage: CoverageGapData,
2871}
2872
2873fn prepare_file_score_coverage_setup<'a>(
2874 modules: &'a [crate::source::ModuleInfo],
2875 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2876 results: &crate::results::AnalysisResults,
2877 graph: &fallow_graph::graph::ModuleGraph,
2878 test_coverage: StaticTestCoverage<'_>,
2879 root: &std::path::Path,
2880) -> FileScoreCoverageSetup<'a> {
2881 let module_by_id: rustc_hash::FxHashMap<_, _> =
2882 modules.iter().map(|m| (m.file_id, m)).collect();
2883 let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
2884 .unused_exports
2885 .iter()
2886 .map(|export| {
2887 (
2888 export.export.path.as_path(),
2889 export.export.export_name.clone(),
2890 )
2891 })
2892 .collect();
2893 let coverage = compute_coverage_gaps(
2894 graph,
2895 test_coverage,
2896 file_paths,
2897 &module_by_id,
2898 &unused_exports,
2899 root,
2900 );
2901 FileScoreCoverageSetup {
2902 module_by_id,
2903 coverage,
2904 }
2905}
2906
2907fn collect_circular_files(
2908 results: &crate::results::AnalysisResults,
2909) -> rustc_hash::FxHashSet<std::path::PathBuf> {
2910 results
2911 .circular_dependencies
2912 .iter()
2913 .flat_map(|c| c.cycle.files.iter().cloned())
2914 .collect()
2915}
2916
2917fn collect_top_complex_fns(
2918 modules: &[crate::source::ModuleInfo],
2919 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2920) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
2921 let mut top_complex_fns = rustc_hash::FxHashMap::default();
2922 for module in modules {
2923 if module.complexity.is_empty() {
2924 continue;
2925 }
2926 let Some(path) = file_paths.get(&module.file_id) else {
2927 continue;
2928 };
2929 let mut funcs: Vec<(String, u32, u16)> = module
2934 .complexity
2935 .iter()
2936 .filter(|f| !fallow_types::extract::is_synthetic_module_unit(&f.name))
2937 .map(|f| (f.name.clone(), f.line, f.cognitive))
2938 .collect();
2939 funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
2940 funcs.truncate(3);
2941 if funcs.first().is_some_and(|worst| worst.2 > 0) {
2942 top_complex_fns.insert((*path).clone(), funcs);
2943 }
2944 }
2945 top_complex_fns
2946}
2947
2948fn collect_cycle_members(
2949 results: &crate::results::AnalysisResults,
2950) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
2951 let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
2952 rustc_hash::FxHashMap::default();
2953 for cycle in &results.circular_dependencies {
2954 for file in &cycle.cycle.files {
2955 let others: Vec<std::path::PathBuf> = cycle
2956 .cycle
2957 .files
2958 .iter()
2959 .filter(|f| *f != file)
2960 .cloned()
2961 .collect();
2962 cycle_members
2963 .entry(file.clone())
2964 .or_default()
2965 .extend(others);
2966 }
2967 }
2968 for members in cycle_members.values_mut() {
2969 members.sort();
2970 members.dedup();
2971 }
2972 cycle_members
2973}
2974
2975fn collect_unused_export_names(
2976 results: &crate::results::AnalysisResults,
2977) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
2978 let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
2979 rustc_hash::FxHashMap::default();
2980 for exp in &results.unused_exports {
2981 unused_export_names
2982 .entry(exp.export.path.clone())
2983 .or_default()
2984 .push(exp.export.export_name.clone());
2985 }
2986 unused_export_names
2987}
2988
2989fn build_analysis_counts_snapshot(
2990 graph: &fallow_graph::graph::ModuleGraph,
2991 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2992 results: &crate::results::AnalysisResults,
2993 unused_deps: usize,
2994) -> AnalysisCountsSnapshot {
2995 let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
2996 graph.modules.len(),
2997 rustc_hash::FxBuildHasher,
2998 );
2999 for module in &graph.modules {
3000 if let Some(path) = file_paths.get(&module.file_id) {
3001 module_export_counts.insert((*path).clone(), module.exports.len());
3002 }
3003 }
3004
3005 let mut unused_export_paths =
3006 Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
3007 unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
3008 unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
3009
3010 let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
3011 unused_dep_package_paths.extend(
3012 results
3013 .unused_dependencies
3014 .iter()
3015 .map(|d| d.dep.path.clone()),
3016 );
3017 unused_dep_package_paths.extend(
3018 results
3019 .unused_dev_dependencies
3020 .iter()
3021 .map(|d| d.dep.path.clone()),
3022 );
3023 unused_dep_package_paths.extend(
3024 results
3025 .unused_optional_dependencies
3026 .iter()
3027 .map(|d| d.dep.path.clone()),
3028 );
3029
3030 AnalysisCountsSnapshot {
3031 unused_file_paths: results
3032 .unused_files
3033 .iter()
3034 .map(|f| f.file.path.clone())
3035 .collect(),
3036 unused_export_paths,
3037 unused_dep_package_paths,
3038 circular_dep_groups: results
3039 .circular_dependencies
3040 .iter()
3041 .map(|c| c.cycle.files.clone())
3042 .collect(),
3043 module_export_counts,
3044 }
3045}
3046
3047#[cfg(test)]
3048mod tests {
3049 use super::super::threshold_overrides::GlobalHealthThresholds;
3050 use super::*;
3051
3052 fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
3055 ThresholdOverrideResolver::new(
3056 &[],
3057 GlobalHealthThresholds {
3058 cyclomatic: 20,
3059 cognitive: 15,
3060 crap,
3061 unit_size: 120,
3062 },
3063 )
3064 }
3065
3066 fn test_override_resolver(
3068 overrides: &[fallow_config::HealthThresholdOverride],
3069 ) -> ThresholdOverrideResolver {
3070 ThresholdOverrideResolver::new(
3071 overrides,
3072 GlobalHealthThresholds {
3073 cyclomatic: 20,
3074 cognitive: 15,
3075 crap: CRAP_THRESHOLD,
3076 unit_size: 120,
3077 },
3078 )
3079 }
3080
3081 fn istanbul_crap_default(
3083 complexity: &[fallow_types::extract::FunctionComplexity],
3084 file_coverage: Option<&IstanbulFileCoverage>,
3085 is_test_reachable: bool,
3086 ) -> IstanbulCrapResult {
3087 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3088 let ceilings = CrapCeilingLookup::new(
3089 CrapScoreThresholds {
3090 resolver: &resolver,
3091 enforce_crap: true,
3092 },
3093 std::path::Path::new("src/test.ts"),
3094 );
3095 compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
3096 }
3097
3098 #[test]
3103 fn an_unmatched_function_scores_the_same_with_and_without_a_coverage_map() {
3104 let temp = tempfile::TempDir::new().unwrap();
3105 let source_path = temp.path().join("src/grade.ts");
3106 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
3107 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
3108
3109 let coverage_path = temp.path().join("coverage-final.json");
3113 write_single_file_istanbul_fixture(
3114 &coverage_path,
3115 &source_path,
3116 &serde_json::json!({
3117 "0": {
3118 "name": "unrelated",
3119 "line": 40,
3120 "decl": { "start": { "line": 40, "column": 0 }, "end": { "line": 40, "column": 9 } },
3121 "loc": { "start": { "line": 40, "column": 20 }, "end": { "line": 44, "column": 1 } }
3122 }
3123 }),
3124 &serde_json::json!({ "0": 1 }),
3125 );
3126 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
3127 let canonical_source = dunce::canonicalize(&source_path).unwrap();
3128 let file_coverage = coverage.get(&canonical_source).unwrap();
3129
3130 let function = make_fn_complexity(10);
3131 let with_map =
3132 istanbul_crap_default(std::slice::from_ref(&function), Some(file_coverage), true);
3133 let estimated = compute_crap_scores_estimated(
3134 std::slice::from_ref(&function),
3135 &rustc_hash::FxHashSet::default(),
3136 true,
3137 fallow_output::CoverageSource::Estimated,
3138 &CrapCeilingLookup::new(
3139 CrapScoreThresholds {
3140 resolver: &test_crap_resolver(CRAP_THRESHOLD),
3141 enforce_crap: true,
3142 },
3143 std::path::Path::new("src/test.ts"),
3144 ),
3145 );
3146
3147 assert_eq!(with_map.matched, 0);
3148 assert!(
3149 (with_map.per_function[0].crap - estimated.per_function[0].crap).abs() < f64::EPSILON,
3150 "a map that attributes nothing must not change the score"
3151 );
3152 assert_eq!(with_map.per_function[0].coverage_pct, None);
3153 }
3154
3155 fn test_istanbul_file_coverage(
3156 functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
3157 relocated: bool,
3158 ) -> IstanbulFileCoverage {
3159 let functions = functions
3160 .into_iter()
3161 .map(
3162 |((name, line, col), coverage_pct)| IstanbulFunctionCoverage {
3163 name,
3164 coverage_pct,
3165 aliases: vec![primary_alias(line, col)],
3166 decl_start: IstanbulPosition::new(line, col),
3167 header_holds_other_fn: false,
3168 header_span: None,
3169 body_span: None,
3170 },
3171 )
3172 .collect();
3173 IstanbulFileCoverage::new(functions, relocated)
3174 }
3175
3176 fn primary_alias(line: u32, col: u32) -> IstanbulAlias {
3177 IstanbulAlias {
3178 position: IstanbulPosition::new(line, col),
3179 primary: true,
3180 }
3181 }
3182
3183 fn secondary_alias(line: u32, col: u32) -> IstanbulAlias {
3184 IstanbulAlias {
3185 position: IstanbulPosition::new(line, col),
3186 primary: false,
3187 }
3188 }
3189
3190 fn body_span(start: (u32, u32), end: (u32, u32)) -> IstanbulSpan {
3191 IstanbulSpan {
3192 start: IstanbulPosition::new(start.0, start.1),
3193 end: IstanbulPosition::new(end.0, end.1),
3194 }
3195 }
3196
3197 fn estimated_crap_default(
3199 complexity: &[fallow_types::extract::FunctionComplexity],
3200 test_referenced_exports: &rustc_hash::FxHashSet<String>,
3201 is_test_reachable: bool,
3202 coverage_source: fallow_output::CoverageSource,
3203 ) -> EstimatedCrapResult {
3204 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3205 let ceilings = CrapCeilingLookup::new(
3206 CrapScoreThresholds {
3207 resolver: &resolver,
3208 enforce_crap: true,
3209 },
3210 std::path::Path::new("src/test.ts"),
3211 );
3212 compute_crap_scores_estimated(
3213 complexity,
3214 test_referenced_exports,
3215 is_test_reachable,
3216 coverage_source,
3217 &ceilings,
3218 )
3219 }
3220
3221 fn compute_file_scores_default(
3223 modules: &[crate::source::ModuleInfo],
3224 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3225 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3226 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
3227 istanbul_coverage: Option<&IstanbulCoverage>,
3228 root: &std::path::Path,
3229 ) -> Result<FileScoreOutput, String> {
3230 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3231 compute_file_scores(
3232 FileScoreComputeInput {
3233 modules,
3234 file_paths,
3235 changed_files,
3236 istanbul_coverage,
3237 root,
3238 crap_thresholds: CrapScoreThresholds {
3239 resolver: &resolver,
3240 enforce_crap: true,
3241 },
3242 },
3243 analysis_output,
3244 )
3245 }
3246
3247 #[test]
3248 fn maintainability_perfect_score() {
3249 assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
3250 }
3251
3252 #[test]
3253 fn crap_resolution_prefers_template_inheritance_over_istanbul() {
3254 let inherit_ctx = TemplateInheritContext {
3255 is_test_reachable: true,
3256 test_referenced_exports: rustc_hash::FxHashSet::default(),
3257 provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
3258 };
3259 let istanbul = IstanbulCoverage {
3260 files: rustc_hash::FxHashMap::default(),
3261 };
3262
3263 let resolution = resolve_crap_coverage(
3264 Some(&inherit_ctx),
3265 Some(&istanbul),
3266 std::path::Path::new("/project/src/app.component.html"),
3267 );
3268
3269 assert!(matches!(
3270 resolution,
3271 CrapCoverageResolution::TemplateInherited(_)
3272 ));
3273 }
3274
3275 #[test]
3276 fn crap_resolution_keeps_istanbul_when_file_is_missing() {
3277 let istanbul = IstanbulCoverage {
3278 files: rustc_hash::FxHashMap::default(),
3279 };
3280
3281 let resolution = resolve_crap_coverage(
3282 None,
3283 Some(&istanbul),
3284 std::path::Path::new("/project/src/missing.ts"),
3285 );
3286
3287 assert!(matches!(
3288 resolution,
3289 CrapCoverageResolution::Istanbul {
3290 file_coverage: None
3291 }
3292 ));
3293 }
3294
3295 #[test]
3296 fn maintainability_clamped_at_zero() {
3297 assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
3298 }
3299
3300 #[test]
3301 fn maintainability_formula_correct() {
3302 let result = compute_maintainability_index(0.5, 0.3, 10, 100);
3303 let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
3304 assert!((result - expected).abs() < 0.01);
3305 }
3306
3307 #[test]
3308 fn maintainability_dead_file_penalty() {
3309 let result = compute_maintainability_index(0.0, 1.0, 0, 100);
3310 assert!((result - 80.0).abs() < f64::EPSILON);
3311 }
3312
3313 #[test]
3314 fn maintainability_fan_out_is_logarithmic() {
3315 let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
3316 let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
3317 let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
3318
3319 assert!(result_10 > 90.0); assert!(result_100 > 84.0); assert!((result_100 - result_200).abs() < f64::EPSILON);
3322 }
3323
3324 #[test]
3325 fn maintainability_fan_out_capped_at_15() {
3326 let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
3327 assert!((result - 65.0).abs() < f64::EPSILON);
3328 }
3329
3330 #[test]
3331 fn maintainability_small_file_dampened() {
3332 let small = compute_maintainability_index(0.40, 0.0, 0, 5);
3333 assert!((small - 98.8).abs() < 0.01);
3334 }
3335
3336 #[test]
3337 fn maintainability_large_file_undampened() {
3338 let large = compute_maintainability_index(0.30, 0.0, 0, 192);
3339 assert!((large - 91.0).abs() < 0.01);
3340 }
3341
3342 #[test]
3343 fn maintainability_small_file_ranks_better_than_complex_large_file() {
3344 let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
3345 let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
3346 assert!(
3347 trivial > nightmare,
3348 "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
3349 );
3350 }
3351
3352 #[test]
3353 fn maintainability_at_dampening_boundary() {
3354 let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
3355 let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
3356 assert!((at_boundary - above_boundary).abs() < 0.01);
3357 }
3358
3359 #[test]
3360 fn maintainability_zero_lines_zero_density_penalty() {
3361 let result = compute_maintainability_index(5.0, 0.0, 0, 0);
3362 assert!((result - 100.0).abs() < f64::EPSILON);
3363 }
3364
3365 #[test]
3366 fn complexity_density_zero_lines() {
3367 assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
3368 }
3369
3370 #[test]
3371 fn complexity_density_normal() {
3372 let result = compute_complexity_density(10, 100);
3373 assert!((result - 0.1).abs() < f64::EPSILON);
3374 }
3375
3376 #[test]
3377 fn complexity_density_high() {
3378 let result = compute_complexity_density(50, 10);
3379 assert!((result - 5.0).abs() < f64::EPSILON);
3380 }
3381
3382 #[test]
3383 fn dead_code_ratio_no_exports() {
3384 let unused_files = rustc_hash::FxHashSet::default();
3385 let unused_map = rustc_hash::FxHashMap::default();
3386 let path = std::path::Path::new("/src/foo.ts");
3387 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3388
3389 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3390 assert!((ratio).abs() < f64::EPSILON);
3391 }
3392
3393 #[test]
3394 fn dead_code_ratio_all_unused_file() {
3395 let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
3396 rustc_hash::FxHashSet::default();
3397 let path = std::path::Path::new("/src/foo.ts");
3398 unused_files.insert(path);
3399 let unused_map = rustc_hash::FxHashMap::default();
3400 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3401
3402 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3403 assert!((ratio - 1.0).abs() < f64::EPSILON);
3404 }
3405
3406 #[test]
3407 fn dead_code_ratio_mix() {
3408 let unused_files = rustc_hash::FxHashSet::default();
3409 let path = std::path::Path::new("/src/foo.ts");
3410
3411 let exports = vec![
3412 fallow_graph::graph::ExportSymbol {
3413 name: crate::source::ExportName::Named("a".into()),
3414 is_type_only: false,
3415 is_side_effect_used: false,
3416 visibility: crate::source::VisibilityTag::None,
3417 expected_unused_reason: None,
3418 span: oxc_span::Span::empty(0),
3419 references: vec![],
3420 reference_paths: Vec::new(),
3421 members: vec![],
3422 },
3423 fallow_graph::graph::ExportSymbol {
3424 name: crate::source::ExportName::Named("b".into()),
3425 is_type_only: false,
3426 is_side_effect_used: false,
3427 visibility: crate::source::VisibilityTag::None,
3428 expected_unused_reason: None,
3429 span: oxc_span::Span::empty(0),
3430 references: vec![],
3431 reference_paths: Vec::new(),
3432 members: vec![],
3433 },
3434 fallow_graph::graph::ExportSymbol {
3435 name: crate::source::ExportName::Named("c".into()),
3436 is_type_only: false,
3437 is_side_effect_used: false,
3438 visibility: crate::source::VisibilityTag::None,
3439 expected_unused_reason: None,
3440 span: oxc_span::Span::empty(0),
3441 references: vec![],
3442 reference_paths: Vec::new(),
3443 members: vec![],
3444 },
3445 fallow_graph::graph::ExportSymbol {
3446 name: crate::source::ExportName::Named("MyType".into()),
3447 is_type_only: true,
3448 is_side_effect_used: false,
3449 visibility: crate::source::VisibilityTag::None,
3450 expected_unused_reason: None,
3451 span: oxc_span::Span::empty(0),
3452 references: vec![],
3453 reference_paths: Vec::new(),
3454 members: vec![],
3455 },
3456 ];
3457
3458 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3459 rustc_hash::FxHashMap::default();
3460 unused_map.insert(path, 2);
3461
3462 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3463 assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
3464 }
3465
3466 #[test]
3467 fn dead_code_ratio_all_type_only_exports() {
3468 let unused_files = rustc_hash::FxHashSet::default();
3469 let path = std::path::Path::new("/src/types.ts");
3470
3471 let exports = vec![fallow_graph::graph::ExportSymbol {
3472 name: crate::source::ExportName::Named("Foo".into()),
3473 is_type_only: true,
3474 is_side_effect_used: false,
3475 visibility: crate::source::VisibilityTag::None,
3476 expected_unused_reason: None,
3477 span: oxc_span::Span::empty(0),
3478 references: vec![],
3479 reference_paths: Vec::new(),
3480 members: vec![],
3481 }];
3482 let unused_map = rustc_hash::FxHashMap::default();
3483
3484 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3485 assert!((ratio).abs() < f64::EPSILON);
3486 }
3487
3488 #[test]
3489 fn aggregate_complexity_empty_module() {
3490 let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
3491
3492 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3493 assert_eq!(cyc, 0);
3494 assert_eq!(cog, 0);
3495 assert_eq!(funcs, 0);
3496 assert_eq!(lines, 0);
3497 }
3498
3499 #[test]
3500 fn aggregate_complexity_single_function() {
3501 let module = crate::source::ModuleInfo {
3502 line_offsets: vec![0, 10, 20, 30, 40], complexity: vec![fallow_types::extract::FunctionComplexity {
3504 name: "doStuff".into(),
3505 is_private_member: false,
3506 line: 1,
3507 col: 0,
3508 cyclomatic: 7,
3509 cognitive: 4,
3510 line_count: 5,
3511 param_count: 0,
3512 react_hook_count: 0,
3513 react_jsx_max_depth: 0,
3514 react_prop_count: 0,
3515 source_hash: None,
3516 contributions: Vec::new(),
3517 }],
3518 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3519 };
3520
3521 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3522 assert_eq!(cyc, 7);
3523 assert_eq!(cog, 4);
3524 assert_eq!(funcs, 1);
3525 assert_eq!(lines, 5);
3526 }
3527
3528 #[test]
3529 fn aggregate_complexity_multiple_functions() {
3530 let module = crate::source::ModuleInfo {
3531 line_offsets: vec![0, 10, 20], complexity: vec![
3533 fallow_types::extract::FunctionComplexity {
3534 name: "a".into(),
3535 is_private_member: false,
3536 line: 1,
3537 col: 0,
3538 cyclomatic: 3,
3539 cognitive: 2,
3540 line_count: 1,
3541 param_count: 0,
3542 react_hook_count: 0,
3543 react_jsx_max_depth: 0,
3544 react_prop_count: 0,
3545 source_hash: None,
3546 contributions: Vec::new(),
3547 },
3548 fallow_types::extract::FunctionComplexity {
3549 name: "b".into(),
3550 is_private_member: false,
3551 line: 2,
3552 col: 0,
3553 cyclomatic: 5,
3554 cognitive: 8,
3555 line_count: 2,
3556 param_count: 0,
3557 react_hook_count: 0,
3558 react_jsx_max_depth: 0,
3559 react_prop_count: 0,
3560 source_hash: None,
3561 contributions: Vec::new(),
3562 },
3563 ],
3564 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3565 };
3566
3567 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3568 assert_eq!(cyc, 8);
3569 assert_eq!(cog, 10);
3570 assert_eq!(funcs, 2);
3571 assert_eq!(lines, 3);
3572 }
3573
3574 #[test]
3575 fn count_unused_exports_empty() {
3576 let exports: Vec<crate::results::UnusedExportFinding> = vec![];
3577 let map = count_unused_exports_by_path(&exports);
3578 assert!(map.is_empty());
3579 }
3580
3581 #[test]
3582 fn count_unused_exports_groups_by_path() {
3583 let exports = vec![
3584 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3585 path: std::path::PathBuf::from("/src/a.ts"),
3586 export_name: "foo".into(),
3587 is_type_only: false,
3588 line: 1,
3589 col: 0,
3590 span_start: 0,
3591 is_re_export: false,
3592 }),
3593 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3594 path: std::path::PathBuf::from("/src/a.ts"),
3595 export_name: "bar".into(),
3596 is_type_only: false,
3597 line: 5,
3598 col: 0,
3599 span_start: 40,
3600 is_re_export: false,
3601 }),
3602 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3603 path: std::path::PathBuf::from("/src/b.ts"),
3604 export_name: "baz".into(),
3605 is_type_only: false,
3606 line: 1,
3607 col: 0,
3608 span_start: 0,
3609 is_re_export: false,
3610 }),
3611 ];
3612 let map = count_unused_exports_by_path(&exports);
3613 assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
3614 assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
3615 }
3616
3617 #[test]
3618 fn dead_code_ratio_all_value_exports_unused() {
3619 let unused_files = rustc_hash::FxHashSet::default();
3620 let path = std::path::Path::new("/src/foo.ts");
3621
3622 let exports = vec![
3623 fallow_graph::graph::ExportSymbol {
3624 name: crate::source::ExportName::Named("a".into()),
3625 is_type_only: false,
3626 is_side_effect_used: false,
3627 visibility: crate::source::VisibilityTag::None,
3628 expected_unused_reason: None,
3629 span: oxc_span::Span::empty(0),
3630 references: vec![],
3631 reference_paths: Vec::new(),
3632 members: vec![],
3633 },
3634 fallow_graph::graph::ExportSymbol {
3635 name: crate::source::ExportName::Named("b".into()),
3636 is_type_only: false,
3637 is_side_effect_used: false,
3638 visibility: crate::source::VisibilityTag::None,
3639 expected_unused_reason: None,
3640 span: oxc_span::Span::empty(0),
3641 references: vec![],
3642 reference_paths: Vec::new(),
3643 members: vec![],
3644 },
3645 ];
3646
3647 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3648 rustc_hash::FxHashMap::default();
3649 unused_map.insert(path, 2);
3650
3651 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3652 assert!((ratio - 1.0).abs() < f64::EPSILON);
3653 }
3654
3655 #[test]
3656 fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
3657 let unused_files = rustc_hash::FxHashSet::default();
3658 let path = std::path::Path::new("/src/foo.ts");
3659
3660 let exports = vec![fallow_graph::graph::ExportSymbol {
3661 name: crate::source::ExportName::Named("a".into()),
3662 is_type_only: false,
3663 is_side_effect_used: false,
3664 visibility: crate::source::VisibilityTag::None,
3665 expected_unused_reason: None,
3666 span: oxc_span::Span::empty(0),
3667 references: vec![],
3668 reference_paths: Vec::new(),
3669 members: vec![],
3670 }];
3671
3672 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3673 rustc_hash::FxHashMap::default();
3674 unused_map.insert(path, 5);
3675
3676 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3677 assert!((ratio - 1.0).abs() < f64::EPSILON);
3678 }
3679
3680 #[test]
3681 fn dead_code_ratio_no_unused_exports_for_path() {
3682 let unused_files = rustc_hash::FxHashSet::default();
3683 let path = std::path::Path::new("/src/clean.ts");
3684
3685 let exports = vec![fallow_graph::graph::ExportSymbol {
3686 name: crate::source::ExportName::Named("used".into()),
3687 is_type_only: false,
3688 is_side_effect_used: false,
3689 visibility: crate::source::VisibilityTag::None,
3690 expected_unused_reason: None,
3691 span: oxc_span::Span::empty(0),
3692 references: vec![],
3693 reference_paths: Vec::new(),
3694 members: vec![],
3695 }];
3696
3697 let unused_map = rustc_hash::FxHashMap::default();
3698 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3699 assert!(ratio.abs() < f64::EPSILON);
3700 }
3701
3702 #[test]
3703 fn complexity_density_zero_cyclomatic_with_lines() {
3704 let result = compute_complexity_density(0, 100);
3705 assert!(result.abs() < f64::EPSILON);
3706 }
3707
3708 #[test]
3709 fn complexity_density_single_line() {
3710 let result = compute_complexity_density(1, 1);
3711 assert!((result - 1.0).abs() < f64::EPSILON);
3712 }
3713
3714 #[test]
3715 fn maintainability_only_complexity_penalty() {
3716 let result = compute_maintainability_index(3.0, 0.0, 0, 100);
3717 assert!((result - 10.0).abs() < f64::EPSILON);
3718 }
3719
3720 #[test]
3721 fn maintainability_only_dead_code_penalty() {
3722 let result = compute_maintainability_index(0.0, 0.5, 0, 100);
3723 assert!((result - 90.0).abs() < f64::EPSILON);
3724 }
3725
3726 #[test]
3727 fn maintainability_fan_out_one() {
3728 let result = compute_maintainability_index(0.0, 0.0, 1, 100);
3729 let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
3730 assert!((result - expected).abs() < 0.01);
3731 }
3732
3733 #[test]
3734 fn maintainability_all_penalties_maxed() {
3735 let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
3736 assert!(result.abs() < f64::EPSILON);
3737 }
3738
3739 #[test]
3740 fn count_unused_exports_single_file_single_export() {
3741 let exports = vec![crate::results::UnusedExportFinding::with_actions(
3742 crate::results::UnusedExport {
3743 path: std::path::PathBuf::from("/src/only.ts"),
3744 export_name: "lonely".into(),
3745 is_type_only: false,
3746 line: 1,
3747 col: 0,
3748 span_start: 0,
3749 is_re_export: false,
3750 },
3751 )];
3752 let map = count_unused_exports_by_path(&exports);
3753 assert_eq!(map.len(), 1);
3754 assert_eq!(
3755 map.get(std::path::Path::new("/src/only.ts")).copied(),
3756 Some(1)
3757 );
3758 }
3759
3760 fn build_test_graph(
3762 files: &[crate::discover::DiscoveredFile],
3763 entry_point_paths: &[std::path::PathBuf],
3764 resolved_modules: &[fallow_graph::resolve::ResolvedModule],
3765 ) -> fallow_graph::graph::ModuleGraph {
3766 let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
3767 .iter()
3768 .map(|p| crate::discover::EntryPoint {
3769 path: p.clone(),
3770 source: crate::discover::EntryPointSource::PackageJsonMain,
3771 })
3772 .collect();
3773 fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
3774 }
3775
3776 fn make_module_info(
3778 file_id: u32,
3779 line_count: usize,
3780 functions: Vec<fallow_types::extract::FunctionComplexity>,
3781 ) -> crate::source::ModuleInfo {
3782 crate::source::ModuleInfo {
3783 line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
3784 complexity: functions,
3785 ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
3786 }
3787 }
3788
3789 fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
3790 FileHealthScore {
3791 path: std::path::PathBuf::from(path),
3792 fan_in: 0,
3793 fan_out: 0,
3794 dead_code_ratio: 0.0,
3795 complexity_density: 0.0,
3796 maintainability_index,
3797 total_cyclomatic: 0,
3798 total_cognitive: 0,
3799 function_count: 1,
3800 lines: 1,
3801 crap_max,
3802 crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
3803 crap_exempted: 0,
3804 crap_effective_threshold: None,
3805 }
3806 }
3807
3808 fn crap_concern_at_default(crap_max: f64) -> f64 {
3809 file_score_crap_concern(
3810 &make_file_score("/src/concern.ts", 100.0, crap_max),
3811 CRAP_THRESHOLD,
3812 )
3813 }
3814
3815 #[test]
3816 fn file_score_crap_concern_tracks_crap_risk_bands() {
3817 assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
3818 assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
3819 assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3820 assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
3821 assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
3822 }
3823
3824 #[test]
3825 fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
3826 let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
3829 at_edge.crap_above_threshold = 1;
3830 at_edge.crap_effective_threshold = Some(500.0);
3831 assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
3832
3833 let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
3834 at_ceiling.crap_above_threshold = 1;
3835 at_ceiling.crap_effective_threshold = Some(500.0);
3836 assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
3837 }
3838
3839 #[test]
3840 fn file_score_crap_concern_zeroes_fully_exempt_file() {
3841 let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
3846 exempt.crap_above_threshold = 0;
3847 exempt.crap_exempted = 2;
3848 exempt.crap_effective_threshold = Some(500.0);
3849 assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
3850 assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
3851 assert_eq!(
3852 file_score_concern_axis(&exempt, CRAP_THRESHOLD),
3853 FileScoreConcern::Structural
3854 );
3855 }
3856
3857 #[test]
3858 fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
3859 let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
3860 score.crap_above_threshold = 0;
3861 score.crap_exempted = 2;
3862 assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
3863 assert!(file_score_fully_crap_exempt(&score, 0.0));
3864 assert_eq!(
3865 file_score_concern_axis(&score, 0.0),
3866 FileScoreConcern::Structural
3867 );
3868 }
3869
3870 #[test]
3871 fn file_score_partial_exemption_keeps_risk_axis() {
3872 let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
3875 mixed.crap_above_threshold = 1;
3876 mixed.crap_exempted = 1;
3877 mixed.crap_effective_threshold = Some(30.0);
3878 assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
3879 assert_eq!(
3880 file_score_concern_axis(&mixed, CRAP_THRESHOLD),
3881 FileScoreConcern::Risk
3882 );
3883 }
3884
3885 #[test]
3886 fn file_score_concern_axis_labels_dominant_signal() {
3887 let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
3888 assert_eq!(
3889 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
3890 FileScoreConcern::Risk
3891 );
3892 assert_eq!(
3893 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
3894 "risk"
3895 );
3896
3897 let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
3898 assert_eq!(
3899 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
3900 FileScoreConcern::Structural
3901 );
3902 assert_eq!(
3903 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
3904 "structure"
3905 );
3906
3907 let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
3908 assert_eq!(
3909 file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
3910 FileScoreConcern::Structural
3911 );
3912 }
3913
3914 #[test]
3915 fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
3916 let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
3917 let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
3918
3919 let mut scores = [low_mi_low_risk, higher_mi_high_risk];
3920 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3921
3922 assert_eq!(
3923 scores[0].path,
3924 std::path::Path::new("/src/higher-mi-high-risk.ts")
3925 );
3926 assert_eq!(
3927 scores[1].path,
3928 std::path::Path::new("/src/low-mi-low-risk.ts")
3929 );
3930 }
3931
3932 #[test]
3933 fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
3934 let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
3935 let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
3936
3937 let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
3938 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3939
3940 assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
3941 assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
3942 }
3943
3944 #[test]
3945 fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
3946 let mut scores = [
3947 make_file_score("/src/b.ts", 70.0, 1.0),
3948 make_file_score("/src/a.ts", 70.0, 1.0),
3949 make_file_score("/src/higher-crap.ts", 70.0, 2.0),
3950 make_file_score("/src/lower-concern.ts", 80.0, 1.0),
3951 ];
3952
3953 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
3954
3955 let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
3956 assert_eq!(
3957 paths,
3958 vec![
3959 std::path::Path::new("/src/higher-crap.ts"),
3960 std::path::Path::new("/src/a.ts"),
3961 std::path::Path::new("/src/b.ts"),
3962 std::path::Path::new("/src/lower-concern.ts"),
3963 ]
3964 );
3965 }
3966
3967 #[test]
3968 fn compute_file_scores_empty_graph() {
3969 let files: Vec<crate::discover::DiscoveredFile> = vec![];
3970 let graph = build_test_graph(&files, &[], &[]);
3971 let modules: Vec<crate::source::ModuleInfo> = vec![];
3972 let file_paths = rustc_hash::FxHashMap::default();
3973
3974 let output = crate::results::DeadCodeAnalysisArtifacts {
3975 results: fallow_types::results::AnalysisResults::default(),
3976 timings: None,
3977 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
3978 modules: None,
3979 files: None,
3980 script_used_packages: rustc_hash::FxHashSet::default(),
3981 file_hashes: rustc_hash::FxHashMap::default(),
3982 };
3983
3984 let result = compute_file_scores_default(
3985 &modules,
3986 &file_paths,
3987 None,
3988 output,
3989 None,
3990 std::path::Path::new("/project"),
3991 )
3992 .unwrap();
3993 assert!(result.scores.is_empty());
3994 assert!(result.circular_files.is_empty());
3995 assert!(result.top_complex_fns.is_empty());
3996 assert!(result.entry_points.is_empty());
3997 assert_eq!(result.analysis_counts.total_exports, 0);
3998 assert_eq!(result.analysis_counts.dead_files, 0);
3999 }
4000
4001 #[test]
4002 fn compute_file_scores_no_graph_returns_error() {
4003 let modules: Vec<crate::source::ModuleInfo> = vec![];
4004 let file_paths = rustc_hash::FxHashMap::default();
4005
4006 let output = crate::results::DeadCodeAnalysisArtifacts {
4007 results: fallow_types::results::AnalysisResults::default(),
4008 timings: None,
4009 graph: None,
4010 modules: None,
4011 files: None,
4012 script_used_packages: rustc_hash::FxHashSet::default(),
4013 file_hashes: rustc_hash::FxHashMap::default(),
4014 };
4015
4016 let result = compute_file_scores_default(
4017 &modules,
4018 &file_paths,
4019 None,
4020 output,
4021 None,
4022 std::path::Path::new("/project"),
4023 );
4024 assert!(result.is_err());
4025 match result {
4026 Err(msg) => assert_eq!(msg, "graph not available"),
4027 Ok(_) => panic!("expected error"),
4028 }
4029 }
4030
4031 #[test]
4032 fn compute_file_scores_single_file_with_function() {
4033 let path_a = std::path::PathBuf::from("/src/a.ts");
4034 let files = vec![crate::discover::DiscoveredFile {
4035 id: crate::discover::FileId(0),
4036 path: path_a.clone(),
4037 size_bytes: 100,
4038 }];
4039
4040 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4041 file_id: crate::discover::FileId(0),
4042 path: path_a.clone(),
4043 exports: vec![fallow_types::extract::ExportInfo {
4044 name: crate::source::ExportName::Named("foo".into()),
4045 local_name: None,
4046 is_type_only: false,
4047 visibility: crate::source::VisibilityTag::None,
4048 expected_unused_reason: None,
4049 span: oxc_span::Span::empty(0),
4050 members: vec![],
4051 is_side_effect_used: false,
4052 super_class: None,
4053 }]
4054 .into(),
4055 ..Default::default()
4056 }];
4057
4058 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4059
4060 let modules = vec![make_module_info(
4061 0,
4062 10,
4063 vec![fallow_types::extract::FunctionComplexity {
4064 name: "foo".into(),
4065 is_private_member: false,
4066 line: 1,
4067 col: 0,
4068 cyclomatic: 5,
4069 cognitive: 3,
4070 line_count: 10,
4071 param_count: 0,
4072 react_hook_count: 0,
4073 react_jsx_max_depth: 0,
4074 react_prop_count: 0,
4075 source_hash: None,
4076 contributions: Vec::new(),
4077 }],
4078 )];
4079
4080 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4081 rustc_hash::FxHashMap::default();
4082 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4083
4084 let output = crate::results::DeadCodeAnalysisArtifacts {
4085 results: fallow_types::results::AnalysisResults::default(),
4086 timings: None,
4087 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4088 modules: None,
4089 files: None,
4090 script_used_packages: rustc_hash::FxHashSet::default(),
4091 file_hashes: rustc_hash::FxHashMap::default(),
4092 };
4093
4094 let result = compute_file_scores_default(
4095 &modules,
4096 &file_paths,
4097 None,
4098 output,
4099 None,
4100 std::path::Path::new("/project"),
4101 )
4102 .unwrap();
4103 assert_eq!(result.scores.len(), 1);
4104
4105 let score = &result.scores[0];
4106 assert_eq!(score.path, path_a);
4107 assert_eq!(score.total_cyclomatic, 5);
4108 assert_eq!(score.total_cognitive, 3);
4109 assert_eq!(score.function_count, 1);
4110 assert_eq!(score.lines, 10);
4111 assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
4112 assert!(score.dead_code_ratio.abs() < f64::EPSILON);
4113 assert!(result.entry_points.contains(&path_a));
4114 }
4115
4116 #[test]
4117 fn compute_file_scores_excludes_barrel_files() {
4118 let path_a = std::path::PathBuf::from("/src/index.ts");
4119 let files = vec![crate::discover::DiscoveredFile {
4120 id: crate::discover::FileId(0),
4121 path: path_a.clone(),
4122 size_bytes: 50,
4123 }];
4124
4125 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4126 file_id: crate::discover::FileId(0),
4127 path: path_a.clone(),
4128 ..Default::default()
4129 }];
4130
4131 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4132
4133 let modules = vec![make_module_info(0, 5, vec![])];
4134
4135 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4136 rustc_hash::FxHashMap::default();
4137 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4138
4139 let output = crate::results::DeadCodeAnalysisArtifacts {
4140 results: fallow_types::results::AnalysisResults::default(),
4141 timings: None,
4142 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4143 modules: None,
4144 files: None,
4145 script_used_packages: rustc_hash::FxHashSet::default(),
4146 file_hashes: rustc_hash::FxHashMap::default(),
4147 };
4148
4149 let result = compute_file_scores_default(
4150 &modules,
4151 &file_paths,
4152 None,
4153 output,
4154 None,
4155 std::path::Path::new("/project"),
4156 )
4157 .unwrap();
4158 assert!(result.scores.is_empty());
4159 }
4160
4161 #[test]
4162 fn compute_file_scores_changed_since_filter() {
4163 let path_a = std::path::PathBuf::from("/src/a.ts");
4164 let path_b = std::path::PathBuf::from("/src/b.ts");
4165 let files = vec![
4166 crate::discover::DiscoveredFile {
4167 id: crate::discover::FileId(0),
4168 path: path_a.clone(),
4169 size_bytes: 100,
4170 },
4171 crate::discover::DiscoveredFile {
4172 id: crate::discover::FileId(1),
4173 path: path_b.clone(),
4174 size_bytes: 100,
4175 },
4176 ];
4177
4178 let resolved_modules = vec![
4179 fallow_graph::resolve::ResolvedModule {
4180 file_id: crate::discover::FileId(0),
4181 path: path_a,
4182 ..Default::default()
4183 },
4184 fallow_graph::resolve::ResolvedModule {
4185 file_id: crate::discover::FileId(1),
4186 path: path_b.clone(),
4187 ..Default::default()
4188 },
4189 ];
4190
4191 let graph = build_test_graph(&files, &[], &resolved_modules);
4192
4193 let modules = vec![
4194 make_module_info(
4195 0,
4196 10,
4197 vec![fallow_types::extract::FunctionComplexity {
4198 name: "fn_a".into(),
4199 is_private_member: false,
4200 line: 1,
4201 col: 0,
4202 cyclomatic: 2,
4203 cognitive: 1,
4204 line_count: 10,
4205 param_count: 0,
4206 react_hook_count: 0,
4207 react_jsx_max_depth: 0,
4208 react_prop_count: 0,
4209 source_hash: None,
4210 contributions: Vec::new(),
4211 }],
4212 ),
4213 make_module_info(
4214 1,
4215 10,
4216 vec![fallow_types::extract::FunctionComplexity {
4217 name: "fn_b".into(),
4218 is_private_member: false,
4219 line: 1,
4220 col: 0,
4221 cyclomatic: 3,
4222 cognitive: 2,
4223 line_count: 10,
4224 param_count: 0,
4225 react_hook_count: 0,
4226 react_jsx_max_depth: 0,
4227 react_prop_count: 0,
4228 source_hash: None,
4229 contributions: Vec::new(),
4230 }],
4231 ),
4232 ];
4233
4234 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4235 rustc_hash::FxHashMap::default();
4236 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4237 file_paths.insert(crate::discover::FileId(1), &files[1].path);
4238
4239 let path_b_check = std::path::PathBuf::from("/src/b.ts");
4240 let mut changed = rustc_hash::FxHashSet::default();
4241 changed.insert(path_b);
4242
4243 let output = crate::results::DeadCodeAnalysisArtifacts {
4244 results: fallow_types::results::AnalysisResults::default(),
4245 timings: None,
4246 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4247 modules: None,
4248 files: None,
4249 script_used_packages: rustc_hash::FxHashSet::default(),
4250 file_hashes: rustc_hash::FxHashMap::default(),
4251 };
4252
4253 let result = compute_file_scores_default(
4254 &modules,
4255 &file_paths,
4256 Some(&changed),
4257 output,
4258 None,
4259 std::path::Path::new("/project"),
4260 )
4261 .unwrap();
4262 assert_eq!(result.scores.len(), 1);
4263 assert_eq!(result.scores[0].path, path_b_check);
4264 }
4265
4266 #[test]
4267 fn compute_file_scores_sorted_by_triage_concern() {
4268 let path_a = std::path::PathBuf::from("/src/a.ts");
4269 let path_b = std::path::PathBuf::from("/src/b.ts");
4270 let files = vec![
4271 crate::discover::DiscoveredFile {
4272 id: crate::discover::FileId(0),
4273 path: path_a.clone(),
4274 size_bytes: 100,
4275 },
4276 crate::discover::DiscoveredFile {
4277 id: crate::discover::FileId(1),
4278 path: path_b.clone(),
4279 size_bytes: 100,
4280 },
4281 ];
4282
4283 let resolved_modules = vec![
4284 fallow_graph::resolve::ResolvedModule {
4285 file_id: crate::discover::FileId(0),
4286 path: path_a.clone(),
4287 ..Default::default()
4288 },
4289 fallow_graph::resolve::ResolvedModule {
4290 file_id: crate::discover::FileId(1),
4291 path: path_b,
4292 ..Default::default()
4293 },
4294 ];
4295
4296 let graph = build_test_graph(&files, &[], &resolved_modules);
4297
4298 let modules = vec![
4299 make_module_info(
4300 0,
4301 10,
4302 vec![fallow_types::extract::FunctionComplexity {
4303 name: "complex_fn".into(),
4304 is_private_member: false,
4305 line: 1,
4306 col: 0,
4307 cyclomatic: 30,
4308 cognitive: 20,
4309 line_count: 10,
4310 param_count: 0,
4311 react_hook_count: 0,
4312 react_jsx_max_depth: 0,
4313 react_prop_count: 0,
4314 source_hash: None,
4315 contributions: Vec::new(),
4316 }],
4317 ),
4318 make_module_info(
4319 1,
4320 100,
4321 vec![fallow_types::extract::FunctionComplexity {
4322 name: "simple_fn".into(),
4323 is_private_member: false,
4324 line: 1,
4325 col: 0,
4326 cyclomatic: 1,
4327 cognitive: 0,
4328 line_count: 100,
4329 param_count: 0,
4330 react_hook_count: 0,
4331 react_jsx_max_depth: 0,
4332 react_prop_count: 0,
4333 source_hash: None,
4334 contributions: Vec::new(),
4335 }],
4336 ),
4337 ];
4338
4339 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4340 rustc_hash::FxHashMap::default();
4341 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4342 file_paths.insert(crate::discover::FileId(1), &files[1].path);
4343
4344 let output = crate::results::DeadCodeAnalysisArtifacts {
4345 results: fallow_types::results::AnalysisResults::default(),
4346 timings: None,
4347 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4348 modules: None,
4349 files: None,
4350 script_used_packages: rustc_hash::FxHashSet::default(),
4351 file_hashes: rustc_hash::FxHashMap::default(),
4352 };
4353
4354 let result = compute_file_scores_default(
4355 &modules,
4356 &file_paths,
4357 None,
4358 output,
4359 None,
4360 std::path::Path::new("/project"),
4361 )
4362 .unwrap();
4363 assert_eq!(result.scores.len(), 2);
4364 assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
4365 assert_eq!(result.scores[0].path, path_a);
4366 }
4367
4368 #[test]
4369 fn compute_file_scores_with_unused_file_populates_evidence() {
4370 let path_a = std::path::PathBuf::from("/src/unused.ts");
4371 let files = vec![crate::discover::DiscoveredFile {
4372 id: crate::discover::FileId(0),
4373 path: path_a.clone(),
4374 size_bytes: 100,
4375 }];
4376
4377 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4378 file_id: crate::discover::FileId(0),
4379 path: path_a.clone(),
4380 exports: vec![fallow_types::extract::ExportInfo {
4381 name: crate::source::ExportName::Named("orphan".into()),
4382 local_name: None,
4383 is_type_only: false,
4384 visibility: crate::source::VisibilityTag::None,
4385 expected_unused_reason: None,
4386 span: oxc_span::Span::empty(0),
4387 members: vec![],
4388 is_side_effect_used: false,
4389 super_class: None,
4390 }]
4391 .into(),
4392 ..Default::default()
4393 }];
4394
4395 let graph = build_test_graph(&files, &[], &resolved_modules);
4396
4397 let modules = vec![make_module_info(
4398 0,
4399 10,
4400 vec![fallow_types::extract::FunctionComplexity {
4401 name: "orphan".into(),
4402 is_private_member: false,
4403 line: 1,
4404 col: 0,
4405 cyclomatic: 1,
4406 cognitive: 0,
4407 line_count: 10,
4408 param_count: 0,
4409 react_hook_count: 0,
4410 react_jsx_max_depth: 0,
4411 react_prop_count: 0,
4412 source_hash: None,
4413 contributions: Vec::new(),
4414 }],
4415 )];
4416
4417 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4418 rustc_hash::FxHashMap::default();
4419 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4420
4421 let mut results = fallow_types::results::AnalysisResults::default();
4422 results.unused_files.push(
4423 fallow_types::output_dead_code::UnusedFileFinding::with_actions(
4424 fallow_types::results::UnusedFile {
4425 path: path_a.clone(),
4426 },
4427 ),
4428 );
4429
4430 let output = crate::results::DeadCodeAnalysisArtifacts {
4431 results,
4432 timings: None,
4433 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4434 modules: None,
4435 files: None,
4436 script_used_packages: rustc_hash::FxHashSet::default(),
4437 file_hashes: rustc_hash::FxHashMap::default(),
4438 };
4439
4440 let result = compute_file_scores_default(
4441 &modules,
4442 &file_paths,
4443 None,
4444 output,
4445 None,
4446 std::path::Path::new("/project"),
4447 )
4448 .unwrap();
4449 assert_eq!(result.scores.len(), 1);
4450 assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
4451 assert!(result.unused_export_names.contains_key(&path_a));
4452 let names = &result.unused_export_names[&path_a];
4453 assert_eq!(names, &["orphan"]);
4454 assert_eq!(result.analysis_counts.dead_files, 1);
4455 }
4456
4457 #[test]
4458 #[expect(
4459 clippy::too_many_lines,
4460 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4461 )]
4462 fn compute_file_scores_tracks_top_complex_functions() {
4463 let path_a = std::path::PathBuf::from("/src/complex.ts");
4464 let files = vec![crate::discover::DiscoveredFile {
4465 id: crate::discover::FileId(0),
4466 path: path_a.clone(),
4467 size_bytes: 500,
4468 }];
4469
4470 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4471 file_id: crate::discover::FileId(0),
4472 path: path_a.clone(),
4473 ..Default::default()
4474 }];
4475
4476 let graph = build_test_graph(&files, &[], &resolved_modules);
4477
4478 let modules = vec![make_module_info(
4479 0,
4480 50,
4481 vec![
4482 fallow_types::extract::FunctionComplexity {
4483 name: "high".into(),
4484 is_private_member: false,
4485 line: 1,
4486 col: 0,
4487 cyclomatic: 10,
4488 cognitive: 20,
4489 line_count: 10,
4490 param_count: 0,
4491 react_hook_count: 0,
4492 react_jsx_max_depth: 0,
4493 react_prop_count: 0,
4494 source_hash: None,
4495 contributions: Vec::new(),
4496 },
4497 fallow_types::extract::FunctionComplexity {
4498 name: "medium".into(),
4499 is_private_member: false,
4500 line: 11,
4501 col: 0,
4502 cyclomatic: 5,
4503 cognitive: 10,
4504 line_count: 10,
4505 param_count: 0,
4506 react_hook_count: 0,
4507 react_jsx_max_depth: 0,
4508 react_prop_count: 0,
4509 source_hash: None,
4510 contributions: Vec::new(),
4511 },
4512 fallow_types::extract::FunctionComplexity {
4513 name: "low".into(),
4514 is_private_member: false,
4515 line: 21,
4516 col: 0,
4517 cyclomatic: 2,
4518 cognitive: 5,
4519 line_count: 10,
4520 param_count: 0,
4521 react_hook_count: 0,
4522 react_jsx_max_depth: 0,
4523 react_prop_count: 0,
4524 source_hash: None,
4525 contributions: Vec::new(),
4526 },
4527 fallow_types::extract::FunctionComplexity {
4528 name: "trivial".into(),
4529 is_private_member: false,
4530 line: 31,
4531 col: 0,
4532 cyclomatic: 1,
4533 cognitive: 1,
4534 line_count: 10,
4535 param_count: 0,
4536 react_hook_count: 0,
4537 react_jsx_max_depth: 0,
4538 react_prop_count: 0,
4539 source_hash: None,
4540 contributions: Vec::new(),
4541 },
4542 ],
4543 )];
4544
4545 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4546 rustc_hash::FxHashMap::default();
4547 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4548
4549 let output = crate::results::DeadCodeAnalysisArtifacts {
4550 results: fallow_types::results::AnalysisResults::default(),
4551 timings: None,
4552 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4553 modules: None,
4554 files: None,
4555 script_used_packages: rustc_hash::FxHashSet::default(),
4556 file_hashes: rustc_hash::FxHashMap::default(),
4557 };
4558
4559 let result = compute_file_scores_default(
4560 &modules,
4561 &file_paths,
4562 None,
4563 output,
4564 None,
4565 std::path::Path::new("/project"),
4566 )
4567 .unwrap();
4568 assert!(result.top_complex_fns.contains_key(&path_a));
4569 let top = &result.top_complex_fns[&path_a];
4570 assert_eq!(top.len(), 3);
4571 assert_eq!(top[0].0, "high");
4572 assert_eq!(top[0].2, 20);
4573 assert_eq!(top[1].0, "medium");
4574 assert_eq!(top[1].2, 10);
4575 assert_eq!(top[2].0, "low");
4576 assert_eq!(top[2].2, 5);
4577 }
4578
4579 #[test]
4580 #[expect(
4581 clippy::too_many_lines,
4582 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4583 )]
4584 fn compute_file_scores_with_circular_deps() {
4585 let path_a = std::path::PathBuf::from("/src/a.ts");
4586 let path_b = std::path::PathBuf::from("/src/b.ts");
4587 let files = vec![
4588 crate::discover::DiscoveredFile {
4589 id: crate::discover::FileId(0),
4590 path: path_a.clone(),
4591 size_bytes: 100,
4592 },
4593 crate::discover::DiscoveredFile {
4594 id: crate::discover::FileId(1),
4595 path: path_b.clone(),
4596 size_bytes: 100,
4597 },
4598 ];
4599
4600 let resolved_modules = vec![
4601 fallow_graph::resolve::ResolvedModule {
4602 file_id: crate::discover::FileId(0),
4603 path: path_a.clone(),
4604 ..Default::default()
4605 },
4606 fallow_graph::resolve::ResolvedModule {
4607 file_id: crate::discover::FileId(1),
4608 path: path_b.clone(),
4609 ..Default::default()
4610 },
4611 ];
4612
4613 let graph = build_test_graph(&files, &[], &resolved_modules);
4614
4615 let modules = vec![
4616 make_module_info(
4617 0,
4618 10,
4619 vec![fallow_types::extract::FunctionComplexity {
4620 name: "fn_a".into(),
4621 is_private_member: false,
4622 line: 1,
4623 col: 0,
4624 cyclomatic: 2,
4625 cognitive: 1,
4626 line_count: 10,
4627 param_count: 0,
4628 react_hook_count: 0,
4629 react_jsx_max_depth: 0,
4630 react_prop_count: 0,
4631 source_hash: None,
4632 contributions: Vec::new(),
4633 }],
4634 ),
4635 make_module_info(
4636 1,
4637 10,
4638 vec![fallow_types::extract::FunctionComplexity {
4639 name: "fn_b".into(),
4640 is_private_member: false,
4641 line: 1,
4642 col: 0,
4643 cyclomatic: 3,
4644 cognitive: 2,
4645 line_count: 10,
4646 param_count: 0,
4647 react_hook_count: 0,
4648 react_jsx_max_depth: 0,
4649 react_prop_count: 0,
4650 source_hash: None,
4651 contributions: Vec::new(),
4652 }],
4653 ),
4654 ];
4655
4656 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4657 rustc_hash::FxHashMap::default();
4658 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4659 file_paths.insert(crate::discover::FileId(1), &files[1].path);
4660
4661 let mut results = fallow_types::results::AnalysisResults::default();
4662 results.circular_dependencies.push(
4663 fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
4664 fallow_types::results::CircularDependency {
4665 files: vec![path_a.clone(), path_b.clone()],
4666 length: 2,
4667 line: 1,
4668 col: 0,
4669 edges: Vec::new(),
4670 is_cross_package: false,
4671 },
4672 ),
4673 );
4674
4675 let output = crate::results::DeadCodeAnalysisArtifacts {
4676 results,
4677 timings: None,
4678 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4679 modules: None,
4680 files: None,
4681 script_used_packages: rustc_hash::FxHashSet::default(),
4682 file_hashes: rustc_hash::FxHashMap::default(),
4683 };
4684
4685 let result = compute_file_scores_default(
4686 &modules,
4687 &file_paths,
4688 None,
4689 output,
4690 None,
4691 std::path::Path::new("/project"),
4692 )
4693 .unwrap();
4694 assert!(result.circular_files.contains(&path_a));
4695 assert!(result.circular_files.contains(&path_b));
4696 assert!(result.cycle_members.contains_key(&path_a));
4697 assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
4698 assert!(result.cycle_members.contains_key(&path_b));
4699 assert_eq!(result.cycle_members[&path_b], vec![path_a]);
4700 assert_eq!(result.analysis_counts.circular_deps, 1);
4701 }
4702
4703 #[test]
4704 #[expect(
4705 clippy::too_many_lines,
4706 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4707 )]
4708 fn compute_file_scores_analysis_counts_unused_exports_and_types() {
4709 let path_a = std::path::PathBuf::from("/src/a.ts");
4710 let files = vec![crate::discover::DiscoveredFile {
4711 id: crate::discover::FileId(0),
4712 path: path_a.clone(),
4713 size_bytes: 100,
4714 }];
4715
4716 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4717 file_id: crate::discover::FileId(0),
4718 path: path_a.clone(),
4719 exports: vec![
4720 fallow_types::extract::ExportInfo {
4721 name: crate::source::ExportName::Named("foo".into()),
4722 local_name: None,
4723 is_type_only: false,
4724 visibility: crate::source::VisibilityTag::None,
4725 expected_unused_reason: None,
4726 span: oxc_span::Span::empty(0),
4727 members: vec![],
4728 is_side_effect_used: false,
4729 super_class: None,
4730 },
4731 fallow_types::extract::ExportInfo {
4732 name: crate::source::ExportName::Named("bar".into()),
4733 local_name: None,
4734 is_type_only: false,
4735 visibility: crate::source::VisibilityTag::None,
4736 expected_unused_reason: None,
4737 span: oxc_span::Span::empty(0),
4738 members: vec![],
4739 is_side_effect_used: false,
4740 super_class: None,
4741 },
4742 ]
4743 .into(),
4744 ..Default::default()
4745 }];
4746
4747 let graph = build_test_graph(&files, &[], &resolved_modules);
4748
4749 let mut module = make_module_info(
4750 0,
4751 10,
4752 vec![fallow_types::extract::FunctionComplexity {
4753 name: "fn_a".into(),
4754 is_private_member: false,
4755 line: 1,
4756 col: 0,
4757 cyclomatic: 1,
4758 cognitive: 0,
4759 line_count: 10,
4760 param_count: 0,
4761 react_hook_count: 0,
4762 react_jsx_max_depth: 0,
4763 react_prop_count: 0,
4764 source_hash: None,
4765 contributions: Vec::new(),
4766 }],
4767 );
4768 module.exports = vec![
4769 fallow_types::extract::ExportInfo {
4770 name: crate::source::ExportName::Named("foo".into()),
4771 local_name: None,
4772 is_type_only: false,
4773 visibility: crate::source::VisibilityTag::None,
4774 expected_unused_reason: None,
4775 span: oxc_span::Span::empty(0),
4776 members: vec![],
4777 is_side_effect_used: false,
4778 super_class: None,
4779 },
4780 fallow_types::extract::ExportInfo {
4781 name: crate::source::ExportName::Named("bar".into()),
4782 local_name: None,
4783 is_type_only: false,
4784 visibility: crate::source::VisibilityTag::None,
4785 expected_unused_reason: None,
4786 span: oxc_span::Span::empty(0),
4787 members: vec![],
4788 is_side_effect_used: false,
4789 super_class: None,
4790 },
4791 ]
4792 .into();
4793 let modules = vec![module];
4794
4795 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4796 rustc_hash::FxHashMap::default();
4797 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4798
4799 let mut results = fallow_types::results::AnalysisResults::default();
4800 results.unused_exports.push(
4801 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
4802 fallow_types::results::UnusedExport {
4803 path: path_a.clone(),
4804 export_name: "foo".into(),
4805 is_type_only: false,
4806 line: 1,
4807 col: 0,
4808 span_start: 0,
4809 is_re_export: false,
4810 },
4811 ),
4812 );
4813 results.unused_types.push(
4814 fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
4815 fallow_types::results::UnusedExport {
4816 path: path_a,
4817 export_name: "MyType".into(),
4818 is_type_only: true,
4819 line: 5,
4820 col: 0,
4821 span_start: 40,
4822 is_re_export: false,
4823 },
4824 ),
4825 );
4826 results.unused_dependencies.push(
4827 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
4828 fallow_types::results::UnusedDependency {
4829 package_name: "lodash".into(),
4830 location: fallow_types::results::DependencyLocation::Dependencies,
4831 path: std::path::PathBuf::from("/package.json"),
4832 line: 1,
4833 used_in_workspaces: Vec::new(),
4834 },
4835 ),
4836 );
4837
4838 let output = crate::results::DeadCodeAnalysisArtifacts {
4839 results,
4840 timings: None,
4841 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4842 modules: None,
4843 files: None,
4844 script_used_packages: rustc_hash::FxHashSet::default(),
4845 file_hashes: rustc_hash::FxHashMap::default(),
4846 };
4847
4848 let result = compute_file_scores_default(
4849 &modules,
4850 &file_paths,
4851 None,
4852 output,
4853 None,
4854 std::path::Path::new("/project"),
4855 )
4856 .unwrap();
4857 assert_eq!(result.analysis_counts.total_exports, 2);
4858 assert_eq!(result.analysis_counts.dead_exports, 2);
4859 assert_eq!(result.analysis_counts.unused_deps, 1);
4860 }
4861
4862 #[test]
4864 #[expect(
4865 clippy::too_many_lines,
4866 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4867 )]
4868 fn total_exports_counts_graph_modules_not_extraction_modules() {
4869 let path_a = std::path::PathBuf::from("/src/a.ts");
4870 let files = vec![crate::discover::DiscoveredFile {
4871 id: crate::discover::FileId(0),
4872 path: path_a.clone(),
4873 size_bytes: 100,
4874 }];
4875
4876 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4877 file_id: crate::discover::FileId(0),
4878 path: path_a.clone(),
4879 exports: vec![
4880 fallow_types::extract::ExportInfo {
4881 name: crate::source::ExportName::Named("foo".into()),
4882 local_name: None,
4883 is_type_only: false,
4884 visibility: crate::source::VisibilityTag::None,
4885 expected_unused_reason: None,
4886 span: oxc_span::Span::empty(0),
4887 members: vec![],
4888 is_side_effect_used: false,
4889 super_class: None,
4890 },
4891 fallow_types::extract::ExportInfo {
4892 name: crate::source::ExportName::Named("bar".into()),
4893 local_name: None,
4894 is_type_only: false,
4895 visibility: crate::source::VisibilityTag::None,
4896 expected_unused_reason: None,
4897 span: oxc_span::Span::empty(0),
4898 members: vec![],
4899 is_side_effect_used: false,
4900 super_class: None,
4901 },
4902 fallow_types::extract::ExportInfo {
4903 name: crate::source::ExportName::Named("baz".into()),
4904 local_name: None,
4905 is_type_only: false,
4906 visibility: crate::source::VisibilityTag::None,
4907 expected_unused_reason: None,
4908 span: oxc_span::Span::new(0, 0),
4909 members: vec![],
4910 is_side_effect_used: false,
4911 super_class: None,
4912 },
4913 ]
4914 .into(),
4915 ..Default::default()
4916 }];
4917
4918 let graph = build_test_graph(&files, &[], &resolved_modules);
4919
4920 let mut module = make_module_info(
4921 0,
4922 10,
4923 vec![fallow_types::extract::FunctionComplexity {
4924 name: "fn_a".into(),
4925 is_private_member: false,
4926 line: 1,
4927 col: 0,
4928 cyclomatic: 1,
4929 cognitive: 0,
4930 line_count: 10,
4931 param_count: 0,
4932 react_hook_count: 0,
4933 react_jsx_max_depth: 0,
4934 react_prop_count: 0,
4935 source_hash: None,
4936 contributions: Vec::new(),
4937 }],
4938 );
4939 module.exports = vec![
4940 fallow_types::extract::ExportInfo {
4941 name: crate::source::ExportName::Named("foo".into()),
4942 local_name: None,
4943 is_type_only: false,
4944 visibility: crate::source::VisibilityTag::None,
4945 expected_unused_reason: None,
4946 span: oxc_span::Span::empty(0),
4947 members: vec![],
4948 is_side_effect_used: false,
4949 super_class: None,
4950 },
4951 fallow_types::extract::ExportInfo {
4952 name: crate::source::ExportName::Named("bar".into()),
4953 local_name: None,
4954 is_type_only: false,
4955 visibility: crate::source::VisibilityTag::None,
4956 expected_unused_reason: None,
4957 span: oxc_span::Span::empty(0),
4958 members: vec![],
4959 is_side_effect_used: false,
4960 super_class: None,
4961 },
4962 ]
4963 .into();
4964 let modules = vec![module];
4965
4966 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4967 rustc_hash::FxHashMap::default();
4968 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4969
4970 let mut results = fallow_types::results::AnalysisResults::default();
4971 for name in ["foo", "bar", "baz"] {
4972 results.unused_exports.push(
4973 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
4974 fallow_types::results::UnusedExport {
4975 path: path_a.clone(),
4976 export_name: name.into(),
4977 is_type_only: false,
4978 line: 1,
4979 col: 0,
4980 span_start: 0,
4981 is_re_export: name == "baz",
4982 },
4983 ),
4984 );
4985 }
4986
4987 let output = crate::results::DeadCodeAnalysisArtifacts {
4988 results,
4989 timings: None,
4990 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4991 modules: None,
4992 files: None,
4993 script_used_packages: rustc_hash::FxHashSet::default(),
4994 file_hashes: rustc_hash::FxHashMap::default(),
4995 };
4996
4997 let result = compute_file_scores_default(
4998 &modules,
4999 &file_paths,
5000 None,
5001 output,
5002 None,
5003 std::path::Path::new("/project"),
5004 )
5005 .unwrap();
5006 assert_eq!(result.analysis_counts.total_exports, 3);
5007 assert_eq!(result.analysis_counts.dead_exports, 3);
5008 }
5009
5010 #[test]
5011 fn compute_file_scores_module_not_in_file_paths_skipped() {
5012 let path_a = std::path::PathBuf::from("/src/a.ts");
5013 let files = vec![crate::discover::DiscoveredFile {
5014 id: crate::discover::FileId(0),
5015 path: path_a.clone(),
5016 size_bytes: 100,
5017 }];
5018
5019 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5020 file_id: crate::discover::FileId(0),
5021 path: path_a,
5022 ..Default::default()
5023 }];
5024
5025 let graph = build_test_graph(&files, &[], &resolved_modules);
5026
5027 let modules = vec![make_module_info(
5028 0,
5029 10,
5030 vec![fallow_types::extract::FunctionComplexity {
5031 name: "fn_a".into(),
5032 is_private_member: false,
5033 line: 1,
5034 col: 0,
5035 cyclomatic: 2,
5036 cognitive: 1,
5037 line_count: 10,
5038 param_count: 0,
5039 react_hook_count: 0,
5040 react_jsx_max_depth: 0,
5041 react_prop_count: 0,
5042 source_hash: None,
5043 contributions: Vec::new(),
5044 }],
5045 )];
5046
5047 let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5048 rustc_hash::FxHashMap::default();
5049
5050 let output = crate::results::DeadCodeAnalysisArtifacts {
5051 results: fallow_types::results::AnalysisResults::default(),
5052 timings: None,
5053 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5054 modules: None,
5055 files: None,
5056 script_used_packages: rustc_hash::FxHashSet::default(),
5057 file_hashes: rustc_hash::FxHashMap::default(),
5058 };
5059
5060 let result = compute_file_scores_default(
5061 &modules,
5062 &file_paths,
5063 None,
5064 output,
5065 None,
5066 std::path::Path::new("/project"),
5067 )
5068 .unwrap();
5069 assert!(result.scores.is_empty());
5070 }
5071
5072 #[test]
5073 fn compute_file_scores_mi_rounded_to_one_decimal() {
5074 let path_a = std::path::PathBuf::from("/src/a.ts");
5075 let files = vec![crate::discover::DiscoveredFile {
5076 id: crate::discover::FileId(0),
5077 path: path_a.clone(),
5078 size_bytes: 100,
5079 }];
5080
5081 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5082 file_id: crate::discover::FileId(0),
5083 path: path_a.clone(),
5084 ..Default::default()
5085 }];
5086
5087 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5088
5089 let modules = vec![make_module_info(
5090 0,
5091 100,
5092 vec![fallow_types::extract::FunctionComplexity {
5093 name: "fn".into(),
5094 is_private_member: false,
5095 line: 1,
5096 col: 0,
5097 cyclomatic: 7,
5098 cognitive: 3,
5099 line_count: 100,
5100 param_count: 0,
5101 react_hook_count: 0,
5102 react_jsx_max_depth: 0,
5103 react_prop_count: 0,
5104 source_hash: None,
5105 contributions: Vec::new(),
5106 }],
5107 )];
5108
5109 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5110 rustc_hash::FxHashMap::default();
5111 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5112
5113 let output = crate::results::DeadCodeAnalysisArtifacts {
5114 results: fallow_types::results::AnalysisResults::default(),
5115 timings: None,
5116 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5117 modules: None,
5118 files: None,
5119 script_used_packages: rustc_hash::FxHashSet::default(),
5120 file_hashes: rustc_hash::FxHashMap::default(),
5121 };
5122
5123 let result = compute_file_scores_default(
5124 &modules,
5125 &file_paths,
5126 None,
5127 output,
5128 None,
5129 std::path::Path::new("/project"),
5130 )
5131 .unwrap();
5132 let mi = result.scores[0].maintainability_index;
5133 let rounded = (mi * 10.0).round() / 10.0;
5134 assert!((mi - rounded).abs() < f64::EPSILON);
5135 }
5136
5137 #[test]
5138 fn compute_file_scores_value_export_counts_tracked() {
5139 let path_a = std::path::PathBuf::from("/src/a.ts");
5140 let files = vec![crate::discover::DiscoveredFile {
5141 id: crate::discover::FileId(0),
5142 path: path_a.clone(),
5143 size_bytes: 100,
5144 }];
5145
5146 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5147 file_id: crate::discover::FileId(0),
5148 path: path_a.clone(),
5149 exports: vec![
5150 fallow_types::extract::ExportInfo {
5151 name: crate::source::ExportName::Named("a".into()),
5152 local_name: None,
5153 is_type_only: false,
5154 visibility: crate::source::VisibilityTag::None,
5155 expected_unused_reason: None,
5156 span: oxc_span::Span::empty(0),
5157 members: vec![],
5158 is_side_effect_used: false,
5159 super_class: None,
5160 },
5161 fallow_types::extract::ExportInfo {
5162 name: crate::source::ExportName::Named("b".into()),
5163 local_name: None,
5164 is_type_only: false,
5165 visibility: crate::source::VisibilityTag::None,
5166 expected_unused_reason: None,
5167 span: oxc_span::Span::empty(0),
5168 members: vec![],
5169 is_side_effect_used: false,
5170 super_class: None,
5171 },
5172 fallow_types::extract::ExportInfo {
5173 name: crate::source::ExportName::Named("T".into()),
5174 local_name: None,
5175 is_type_only: true,
5176 visibility: crate::source::VisibilityTag::None,
5177 expected_unused_reason: None,
5178 span: oxc_span::Span::empty(0),
5179 members: vec![],
5180 is_side_effect_used: false,
5181 super_class: None,
5182 },
5183 ]
5184 .into(),
5185 ..Default::default()
5186 }];
5187
5188 let graph = build_test_graph(&files, &[], &resolved_modules);
5189
5190 let modules = vec![make_module_info(
5191 0,
5192 10,
5193 vec![fallow_types::extract::FunctionComplexity {
5194 name: "fn_a".into(),
5195 is_private_member: false,
5196 line: 1,
5197 col: 0,
5198 cyclomatic: 2,
5199 cognitive: 1,
5200 line_count: 10,
5201 param_count: 0,
5202 react_hook_count: 0,
5203 react_jsx_max_depth: 0,
5204 react_prop_count: 0,
5205 source_hash: None,
5206 contributions: Vec::new(),
5207 }],
5208 )];
5209
5210 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5211 rustc_hash::FxHashMap::default();
5212 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5213
5214 let output = crate::results::DeadCodeAnalysisArtifacts {
5215 results: fallow_types::results::AnalysisResults::default(),
5216 timings: None,
5217 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5218 modules: None,
5219 files: None,
5220 script_used_packages: rustc_hash::FxHashSet::default(),
5221 file_hashes: rustc_hash::FxHashMap::default(),
5222 };
5223
5224 let result = compute_file_scores_default(
5225 &modules,
5226 &file_paths,
5227 None,
5228 output,
5229 None,
5230 std::path::Path::new("/project"),
5231 )
5232 .unwrap();
5233 assert_eq!(result.value_export_counts[&path_a], 2);
5234 }
5235
5236 #[test]
5237 fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
5238 let path_a = std::path::PathBuf::from("/src/simple.ts");
5239 let files = vec![crate::discover::DiscoveredFile {
5240 id: crate::discover::FileId(0),
5241 path: path_a.clone(),
5242 size_bytes: 100,
5243 }];
5244
5245 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5246 file_id: crate::discover::FileId(0),
5247 path: path_a.clone(),
5248 ..Default::default()
5249 }];
5250
5251 let graph = build_test_graph(&files, &[], &resolved_modules);
5252
5253 let modules = vec![make_module_info(
5254 0,
5255 10,
5256 vec![fallow_types::extract::FunctionComplexity {
5257 name: "trivial".into(),
5258 is_private_member: false,
5259 line: 1,
5260 col: 0,
5261 cyclomatic: 1,
5262 cognitive: 0,
5263 line_count: 10,
5264 param_count: 0,
5265 react_hook_count: 0,
5266 react_jsx_max_depth: 0,
5267 react_prop_count: 0,
5268 source_hash: None,
5269 contributions: Vec::new(),
5270 }],
5271 )];
5272
5273 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5274 rustc_hash::FxHashMap::default();
5275 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5276
5277 let output = crate::results::DeadCodeAnalysisArtifacts {
5278 results: fallow_types::results::AnalysisResults::default(),
5279 timings: None,
5280 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5281 modules: None,
5282 files: None,
5283 script_used_packages: rustc_hash::FxHashSet::default(),
5284 file_hashes: rustc_hash::FxHashMap::default(),
5285 };
5286
5287 let result = compute_file_scores_default(
5288 &modules,
5289 &file_paths,
5290 None,
5291 output,
5292 None,
5293 std::path::Path::new("/project"),
5294 )
5295 .unwrap();
5296 assert!(!result.top_complex_fns.contains_key(&path_a));
5297 }
5298
5299 fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
5300 fallow_types::extract::FunctionComplexity {
5301 name: "test_fn".into(),
5302 is_private_member: false,
5303 line: 1,
5304 col: 0,
5305 cyclomatic,
5306 cognitive: 0,
5307 line_count: 10,
5308 param_count: 0,
5309 react_hook_count: 0,
5310 react_jsx_max_depth: 0,
5311 react_prop_count: 0,
5312 source_hash: None,
5313 contributions: Vec::new(),
5314 }
5315 }
5316
5317 fn make_named_fn_complexity(
5318 name: &str,
5319 line: u32,
5320 cyclomatic: u16,
5321 ) -> fallow_types::extract::FunctionComplexity {
5322 fallow_types::extract::FunctionComplexity {
5323 name: name.into(),
5324 is_private_member: false,
5325 line,
5326 col: 0,
5327 cyclomatic,
5328 cognitive: 0,
5329 line_count: 10,
5330 param_count: 0,
5331 react_hook_count: 0,
5332 react_jsx_max_depth: 0,
5333 react_prop_count: 0,
5334 source_hash: None,
5335 contributions: Vec::new(),
5336 }
5337 }
5338
5339 fn crap_override_entry(
5340 files: &[&str],
5341 functions: &[&str],
5342 max_crap: Option<f64>,
5343 ) -> fallow_config::HealthThresholdOverride {
5344 fallow_config::HealthThresholdOverride {
5345 files: files.iter().map(ToString::to_string).collect(),
5346 functions: functions.iter().map(ToString::to_string).collect(),
5347 max_cyclomatic: None,
5348 max_cognitive: None,
5349 max_crap,
5350 max_unit_size: None,
5351 reason: Some("test override".into()),
5352 }
5353 }
5354
5355 fn estimated_signals_with(
5356 resolver: &ThresholdOverrideResolver,
5357 relative: &str,
5358 enforce_crap: bool,
5359 complexity: &[fallow_types::extract::FunctionComplexity],
5360 ) -> CrapThresholdSignals {
5361 let ceilings = CrapCeilingLookup::new(
5362 CrapScoreThresholds {
5363 resolver,
5364 enforce_crap,
5365 },
5366 std::path::Path::new(relative),
5367 );
5368 compute_crap_scores_estimated(
5369 complexity,
5370 &rustc_hash::FxHashSet::default(),
5371 false,
5372 fallow_output::CoverageSource::Estimated,
5373 &ceilings,
5374 )
5375 .signals
5376 }
5377
5378 #[test]
5379 fn crap_counting_exempts_functions_under_override_ceiling() {
5380 let resolver =
5383 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5384 let fns = vec![
5385 make_named_fn_complexity("a", 1, 10),
5386 make_named_fn_complexity("b", 12, 10),
5387 ];
5388
5389 let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5390 assert_eq!(covered.above, 0);
5391 assert_eq!(covered.exempted, 2);
5392 assert_eq!(covered.min_ceiling, Some(500.0));
5393
5394 let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
5395 assert_eq!(elsewhere.above, 2);
5396 assert_eq!(elsewhere.exempted, 0);
5397 assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
5398 }
5399
5400 #[test]
5401 fn crap_counting_insufficient_override_keeps_count() {
5402 let resolver =
5403 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
5404 let fns = vec![
5405 make_named_fn_complexity("a", 1, 10),
5406 make_named_fn_complexity("b", 12, 10),
5407 ];
5408
5409 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5410 assert_eq!(signals.above, 2);
5411 assert_eq!(signals.exempted, 0);
5412 assert_eq!(signals.min_ceiling, Some(50.0));
5413 }
5414
5415 #[test]
5416 fn crap_counting_partial_function_override() {
5417 let resolver =
5420 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
5421 let fns = vec![
5422 make_named_fn_complexity("a", 1, 10),
5423 make_named_fn_complexity("b", 12, 10),
5424 ];
5425
5426 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5427 assert_eq!(signals.above, 1);
5428 assert_eq!(signals.exempted, 1);
5429 assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
5430 }
5431
5432 #[test]
5433 fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
5434 let resolver = test_crap_resolver(0.0);
5437 let fns = vec![
5438 make_named_fn_complexity("a", 1, 10),
5439 make_named_fn_complexity("b", 12, 10),
5440 make_named_fn_complexity("tiny", 24, 1),
5441 ];
5442
5443 let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
5444 assert_eq!(signals.above, 0);
5445 assert_eq!(signals.exempted, 2);
5446 }
5447
5448 #[test]
5449 fn crap_counting_stricter_ceiling_never_counts_exempt() {
5450 let resolver = test_crap_resolver(10.0);
5453 let fns = vec![make_named_fn_complexity("a", 1, 4)]; let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
5456 assert_eq!(signals.above, 1);
5457 assert_eq!(signals.exempted, 0);
5458 }
5459
5460 #[test]
5461 fn crap_counting_uses_rounded_value_at_boundary() {
5462 let funcs = vec![make_fn_complexity(10)];
5467 let mut functions = rustc_hash::FxHashMap::default();
5468 functions.insert(("test_fn".to_string(), 1, 0), 41.56);
5469 let file_cov = test_istanbul_file_coverage(functions, false);
5470
5471 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5472 assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
5473 assert_eq!(result.signals.above, 1);
5474 assert_eq!(result.signals.exempted, 0);
5475 }
5476
5477 #[test]
5478 fn compute_file_scores_discloses_override_exemption_on_row() {
5479 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5480 let files = vec![crate::discover::DiscoveredFile {
5481 id: crate::discover::FileId(0),
5482 path: path_a.clone(),
5483 size_bytes: 100,
5484 }];
5485 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5486 file_id: crate::discover::FileId(0),
5487 path: path_a.clone(),
5488 ..Default::default()
5489 }];
5490 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5491 let modules = vec![make_module_info(
5492 0,
5493 26,
5494 vec![
5495 make_named_fn_complexity("a", 1, 10),
5496 make_named_fn_complexity("b", 12, 10),
5497 ],
5498 )];
5499 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5500 rustc_hash::FxHashMap::default();
5501 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5502 let output = crate::results::DeadCodeAnalysisArtifacts {
5503 results: fallow_types::results::AnalysisResults::default(),
5504 timings: None,
5505 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5506 modules: None,
5507 files: None,
5508 script_used_packages: rustc_hash::FxHashSet::default(),
5509 file_hashes: rustc_hash::FxHashMap::default(),
5510 };
5511
5512 let resolver =
5513 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5514 let result = compute_file_scores(
5515 FileScoreComputeInput {
5516 modules: &modules,
5517 file_paths: &file_paths,
5518 changed_files: None,
5519 istanbul_coverage: None,
5520 root: std::path::Path::new("/project"),
5521 crap_thresholds: CrapScoreThresholds {
5522 resolver: &resolver,
5523 enforce_crap: true,
5524 },
5525 },
5526 output,
5527 )
5528 .unwrap();
5529
5530 assert_eq!(result.scores.len(), 1);
5531 let score = &result.scores[0];
5532 assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
5533 assert_eq!(score.crap_above_threshold, 0);
5534 assert_eq!(score.crap_exempted, 2);
5535 assert_eq!(score.crap_effective_threshold, Some(500.0));
5536 assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
5537 assert_eq!(
5538 file_score_concern_axis(score, CRAP_THRESHOLD),
5539 FileScoreConcern::Structural
5540 );
5541 }
5542
5543 #[test]
5544 fn compute_file_scores_raised_global_omits_row_threshold() {
5545 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
5546 let files = vec![crate::discover::DiscoveredFile {
5547 id: crate::discover::FileId(0),
5548 path: path_a.clone(),
5549 size_bytes: 100,
5550 }];
5551 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5552 file_id: crate::discover::FileId(0),
5553 path: path_a.clone(),
5554 ..Default::default()
5555 }];
5556 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5557 let modules = vec![make_module_info(
5558 0,
5559 26,
5560 vec![
5561 make_named_fn_complexity("a", 1, 10),
5562 make_named_fn_complexity("b", 12, 10),
5563 ],
5564 )];
5565 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5566 rustc_hash::FxHashMap::default();
5567 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5568 let output = crate::results::DeadCodeAnalysisArtifacts {
5569 results: fallow_types::results::AnalysisResults::default(),
5570 timings: None,
5571 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5572 modules: None,
5573 files: None,
5574 script_used_packages: rustc_hash::FxHashSet::default(),
5575 file_hashes: rustc_hash::FxHashMap::default(),
5576 };
5577
5578 let resolver = test_crap_resolver(5000.0);
5581 let result = compute_file_scores(
5582 FileScoreComputeInput {
5583 modules: &modules,
5584 file_paths: &file_paths,
5585 changed_files: None,
5586 istanbul_coverage: None,
5587 root: std::path::Path::new("/project"),
5588 crap_thresholds: CrapScoreThresholds {
5589 resolver: &resolver,
5590 enforce_crap: true,
5591 },
5592 },
5593 output,
5594 )
5595 .unwrap();
5596
5597 assert_eq!(result.scores.len(), 1);
5598 let score = &result.scores[0];
5599 assert_eq!(score.crap_above_threshold, 0);
5600 assert_eq!(score.crap_exempted, 2);
5601 assert_eq!(score.crap_effective_threshold, None);
5602 assert!(file_score_fully_crap_exempt(score, 5000.0));
5603 assert_eq!(
5604 file_score_concern_axis(score, 5000.0),
5605 FileScoreConcern::Structural
5606 );
5607 }
5608
5609 #[test]
5614 fn estimated_crap_untested_aggregates_over_real_units_only() {
5615 let funcs = vec![
5616 make_named_fn_complexity("below", 1, 4),
5617 make_named_fn_complexity("at_threshold", 2, 5),
5618 make_named_fn_complexity("above_threshold", 3, 8),
5619 make_named_fn_complexity("<template>", 4, 21),
5620 make_named_fn_complexity("<module>", 5, 21),
5621 ];
5622 let result = estimated_crap_default(
5623 &funcs,
5624 &rustc_hash::FxHashSet::default(),
5625 false,
5626 fallow_output::CoverageSource::Estimated,
5627 );
5628 assert!((result.max_crap - 72.0).abs() < f64::EPSILON, "{result:#?}");
5629 assert_eq!(result.signals.above, 2);
5630 assert_eq!(result.per_function.len(), 3);
5631 }
5632
5633 #[test]
5634 fn crap_formula_full_coverage() {
5635 let result = crap_formula(10.0, 100.0);
5636 assert!((result - 10.0).abs() < f64::EPSILON);
5637 }
5638
5639 #[test]
5640 fn crap_formula_zero_coverage() {
5641 let result = crap_formula(5.0, 0.0);
5642 assert!((result - 30.0).abs() < f64::EPSILON);
5643 }
5644
5645 #[test]
5646 fn crap_formula_partial_coverage() {
5647 let result = crap_formula(10.0, 50.0);
5648 assert!((result - 22.5).abs() < f64::EPSILON);
5649 }
5650
5651 #[test]
5652 fn crap_formula_high_coverage_low_complexity() {
5653 let result = crap_formula(2.0, 90.0);
5654 assert!((result - 2.004).abs() < 0.001);
5655 }
5656
5657 #[test]
5662 fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
5663 for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
5664 assert!(
5665 crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
5666 "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
5667 );
5668 assert!(
5669 crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
5670 "cyclomatic {} at {coverage_pct}% must stay under the gate",
5671 gate_cc - 1.0
5672 );
5673 }
5674 }
5675
5676 #[test]
5677 fn istanbul_crap_excludes_synthetic_template_units() {
5678 let funcs = vec![
5679 make_named_fn_complexity("<template>", 1, 21),
5680 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5681 make_fn_complexity(6),
5682 ];
5683 let result = istanbul_crap_default(&funcs, None, false);
5684 assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
5685 assert_eq!(result.signals.above, 1);
5686 assert_eq!(
5687 result.total, 1,
5688 "template units must not count as unmatched"
5689 );
5690 assert_eq!(result.per_function.len(), 1);
5691 }
5692
5693 #[test]
5694 fn estimated_crap_excludes_synthetic_template_units() {
5695 let funcs = vec![
5696 make_named_fn_complexity("<template>", 1, 21),
5697 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
5698 ];
5699 let result = estimated_crap_default(
5700 &funcs,
5701 &rustc_hash::FxHashSet::default(),
5702 false,
5703 fallow_output::CoverageSource::Estimated,
5704 );
5705 assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
5706 assert_eq!(result.signals.above, 0);
5707 assert!(result.per_function.is_empty());
5708 }
5709
5710 #[test]
5711 fn istanbul_crap_with_coverage_data() {
5712 let funcs = vec![make_fn_complexity(10)];
5713 let mut functions = rustc_hash::FxHashMap::default();
5714 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
5715 let file_cov = test_istanbul_file_coverage(functions, false);
5716 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5717 assert!((result.max_crap - 10.8).abs() < 0.1);
5718 assert_eq!(result.signals.above, 0);
5719 }
5720
5721 #[test]
5722 fn istanbul_crap_falls_back_to_binary_when_no_match() {
5723 let funcs = vec![make_fn_complexity(6)];
5724 let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
5725 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5726 assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
5727 assert_eq!(result.signals.above, 1);
5728 }
5729
5730 #[test]
5733 fn istanbul_crap_uses_the_static_estimate_when_no_file_coverage() {
5734 let funcs = vec![make_fn_complexity(5)];
5735 let result = istanbul_crap_default(&funcs, None, true);
5736 assert!((result.max_crap - 10.4).abs() < 1e-9);
5739 assert_eq!(result.signals.above, 0);
5740 }
5741
5742 #[test]
5743 fn istanbul_crap_zero_coverage_matches_binary_untested() {
5744 let funcs = vec![make_fn_complexity(5)];
5745 let mut functions = rustc_hash::FxHashMap::default();
5746 functions.insert(("test_fn".to_string(), 1, 0), 0.0);
5747 let file_cov = test_istanbul_file_coverage(functions, false);
5748 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5749 assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
5750 assert_eq!(result.signals.above, 1);
5751 }
5752
5753 #[test]
5754 fn estimated_crap_direct_test_reference() {
5755 let funcs = vec![make_fn_complexity(10)];
5756 let mut refs = rustc_hash::FxHashSet::default();
5757 refs.insert("test_fn".to_string());
5758 let result = estimated_crap_default(
5759 &funcs,
5760 &refs,
5761 true,
5762 fallow_output::CoverageSource::Estimated,
5763 );
5764 let (max, above) = (result.max_crap, result.signals.above);
5765 assert!((max - 10.3).abs() < 0.1);
5766 assert_eq!(above, 0);
5767 }
5768
5769 #[test]
5770 fn estimated_crap_indirect_test_reachable() {
5771 let funcs = vec![make_fn_complexity(10)];
5772 let refs = rustc_hash::FxHashSet::default();
5773 let result = estimated_crap_default(
5774 &funcs,
5775 &refs,
5776 true,
5777 fallow_output::CoverageSource::Estimated,
5778 );
5779 let (max, above) = (result.max_crap, result.signals.above);
5780 assert!((max - 31.6).abs() < 0.1);
5781 assert_eq!(above, 1);
5782 }
5783
5784 #[test]
5785 fn estimated_crap_untested_file() {
5786 let funcs = vec![make_fn_complexity(5)];
5787 let refs = rustc_hash::FxHashSet::default();
5788 let result = estimated_crap_default(
5789 &funcs,
5790 &refs,
5791 false,
5792 fallow_output::CoverageSource::Estimated,
5793 );
5794 let (max, above) = (result.max_crap, result.signals.above);
5795 assert!((max - 30.0).abs() < f64::EPSILON);
5796 assert_eq!(above, 1);
5797 }
5798
5799 #[test]
5800 fn estimated_crap_low_complexity_direct_ref() {
5801 let funcs = vec![make_fn_complexity(2)];
5802 let mut refs = rustc_hash::FxHashSet::default();
5803 refs.insert("test_fn".to_string());
5804 let result = estimated_crap_default(
5805 &funcs,
5806 &refs,
5807 true,
5808 fallow_output::CoverageSource::Estimated,
5809 );
5810 let (max, above) = (result.max_crap, result.signals.above);
5811 assert!(max < 3.0);
5812 assert_eq!(above, 0);
5813 }
5814
5815 #[test]
5816 fn estimated_crap_empty() {
5817 let refs = rustc_hash::FxHashSet::default();
5818 let result =
5819 estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
5820 let (max, above) = (result.max_crap, result.signals.above);
5821 assert!((max).abs() < f64::EPSILON);
5822 assert_eq!(above, 0);
5823 }
5824
5825 fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
5826 fallow_graph::graph::ExportSymbol {
5827 name: fallow_types::extract::ExportName::Named(name.into()),
5828 is_type_only,
5829 is_side_effect_used: false,
5830 visibility: crate::source::VisibilityTag::None,
5831 expected_unused_reason: None,
5832 span: oxc_span::Span::default(),
5833 references: vec![],
5834 reference_paths: Vec::new(),
5835 members: vec![],
5836 }
5837 }
5838
5839 #[test]
5840 fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
5841 let path = std::path::Path::new("src/types.ts");
5842 let exports = vec![
5843 make_export("MyInterface", true),
5844 make_export("MyType", true),
5845 make_export("myFunction", false),
5846 ];
5847 let unused_files = rustc_hash::FxHashSet::default();
5848 let mut unused_by_path = rustc_hash::FxHashMap::default();
5849 unused_by_path.insert(path, 1_usize); let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5852 assert!((ratio - 1.0).abs() < f64::EPSILON);
5853 }
5854
5855 #[test]
5856 fn dead_code_ratio_only_type_exports_returns_zero() {
5857 let path = std::path::Path::new("src/types.ts");
5858 let exports = vec![
5859 make_export("MyInterface", true),
5860 make_export("MyType", true),
5861 ];
5862 let unused_files = rustc_hash::FxHashSet::default();
5863 let unused_by_path = rustc_hash::FxHashMap::default();
5864
5865 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5866 assert!(ratio.abs() < f64::EPSILON);
5867 }
5868
5869 #[test]
5870 fn dead_code_ratio_mixed_exports_counts_only_values() {
5871 let path = std::path::Path::new("src/component.ts");
5872 let exports = vec![
5873 make_export("Props", true),
5874 make_export("State", true),
5875 make_export("Component", false),
5876 make_export("helper", false),
5877 ];
5878 let unused_files = rustc_hash::FxHashSet::default();
5879 let mut unused_by_path = rustc_hash::FxHashMap::default();
5880 unused_by_path.insert(path, 1_usize);
5881
5882 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
5883 assert!((ratio - 0.5).abs() < f64::EPSILON);
5884 }
5885
5886 fn write_single_file_istanbul_fixture(
5887 coverage_path: &std::path::Path,
5888 source_path: &std::path::Path,
5889 fn_map: &serde_json::Value,
5890 function_hits: &serde_json::Value,
5891 ) {
5892 write_single_file_istanbul_fixture_with_statements(
5893 coverage_path,
5894 source_path,
5895 fn_map,
5896 function_hits,
5897 &serde_json::json!({}),
5898 &serde_json::json!({}),
5899 );
5900 }
5901
5902 fn write_single_file_istanbul_fixture_with_statements(
5903 coverage_path: &std::path::Path,
5904 source_path: &std::path::Path,
5905 fn_map: &serde_json::Value,
5906 function_hits: &serde_json::Value,
5907 statement_map: &serde_json::Value,
5908 statement_hits: &serde_json::Value,
5909 ) {
5910 let mut root = serde_json::Map::new();
5911 root.insert(
5912 source_path.to_string_lossy().into_owned(),
5913 serde_json::json!({
5914 "path": source_path.to_string_lossy().into_owned(),
5915 "statementMap": statement_map,
5916 "fnMap": fn_map,
5917 "branchMap": {},
5918 "s": statement_hits,
5919 "f": function_hits,
5920 "b": {}
5921 }),
5922 );
5923
5924 std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
5925 }
5926
5927 #[test]
5933 fn a_negative_branch_coordinate_does_not_cost_the_whole_map() {
5934 let temp = tempfile::TempDir::new().unwrap();
5935 let source_path = temp.path().join("src/pick.ts");
5936 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5937 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
5938
5939 let coverage_path = temp.path().join("coverage-final.json");
5940 let source = source_path.to_string_lossy().into_owned();
5941 std::fs::write(
5942 &coverage_path,
5943 serde_json::to_string(&serde_json::json!({
5944 source.clone(): {
5945 "path": source,
5946 "statementMap": {},
5947 "fnMap": {
5948 "0": {
5949 "name": "pick",
5950 "line": 1,
5951 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 20 } },
5952 "loc": { "start": { "line": 1, "column": 41 }, "end": { "line": 6, "column": 1 } }
5953 }
5954 },
5955 "branchMap": {
5956 "0": {
5957 "type": "branch",
5958 "line": 5,
5959 "loc": {
5960 "start": { "line": 5, "column": -1 },
5961 "end": { "line": 6, "column": 0 }
5962 },
5963 "locations": [
5964 {
5965 "start": { "line": 5, "column": -1 },
5966 "end": { "line": 6, "column": 0 }
5967 }
5968 ]
5969 }
5970 },
5971 "s": {},
5972 "f": { "0": 2 },
5973 "b": { "0": [1, 0] }
5974 }
5975 }))
5976 .unwrap(),
5977 )
5978 .unwrap();
5979
5980 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
5981 let canonical_source = dunce::canonicalize(&source_path).unwrap();
5982 let file_coverage = coverage.get(&canonical_source).unwrap();
5983
5984 assert_eq!(file_coverage.lookup("pick", 1, 16), Some(100.0));
5985 }
5986
5987 #[test]
5991 fn an_accessor_answers_to_its_property_name() {
5992 let temp = tempfile::TempDir::new().unwrap();
5993 let source_path = temp.path().join("src/box.ts");
5994 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
5995 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
5996
5997 let coverage_path = temp.path().join("coverage-final.json");
5998 write_single_file_istanbul_fixture(
5999 &coverage_path,
6000 &source_path,
6001 &serde_json::json!({
6002 "0": {
6003 "name": "get area",
6004 "line": 4,
6005 "decl": { "start": { "line": 4, "column": 6 }, "end": { "line": 4, "column": 10 } },
6006 "loc": { "start": { "line": 4, "column": 13 }, "end": { "line": 6, "column": 3 } }
6007 }
6008 }),
6009 &serde_json::json!({ "0": 0 }),
6010 );
6011
6012 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6013 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6014 let file_coverage = coverage.get(&canonical_source).unwrap();
6015
6016 assert_eq!(file_coverage.lookup("area", 4, 6), Some(0.0));
6018 assert_eq!(file_coverage.lookup("get area", 4, 6), Some(0.0));
6020 }
6021
6022 #[test]
6023 fn resolve_relative_to_root_joins_relative_with_project_root() {
6024 let resolved = resolve_relative_to_root(
6025 std::path::Path::new("coverage/coverage-final.json"),
6026 Some(std::path::Path::new("/work/my-app")),
6027 );
6028 assert_eq!(
6029 resolved,
6030 std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
6031 );
6032 }
6033
6034 #[test]
6035 fn resolve_relative_to_root_returns_absolute_unchanged() {
6036 let resolved = resolve_relative_to_root(
6037 std::path::Path::new("/tmp/coverage-final.json"),
6038 Some(std::path::Path::new("/work/my-app")),
6039 );
6040 assert_eq!(
6041 resolved,
6042 std::path::PathBuf::from("/tmp/coverage-final.json")
6043 );
6044 }
6045
6046 #[test]
6047 fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
6048 let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
6049 let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
6050 assert_eq!(resolved, path);
6051 }
6052
6053 #[cfg(windows)]
6054 #[test]
6055 fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
6056 let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
6057 let resolved =
6058 resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
6059 assert_eq!(resolved, path);
6060 }
6061
6062 #[test]
6063 fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
6064 let resolved =
6065 resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
6066 assert_eq!(
6067 resolved,
6068 std::path::PathBuf::from("coverage/coverage-final.json")
6069 );
6070 }
6071
6072 #[test]
6076 fn load_istanbul_coverage_resolves_relative_map_keys_against_project_root() {
6077 let temp = tempfile::TempDir::new().unwrap();
6078 let source_path = temp.path().join("src/index.ts");
6079 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6080 std::fs::write(&source_path, "export function f(){}").unwrap();
6081
6082 let coverage_path = temp.path().join("coverage-final.json");
6083 std::fs::write(
6084 &coverage_path,
6085 serde_json::to_string(&serde_json::json!({
6086 "src/index.ts": {
6087 "path": "src/index.ts",
6088 "statementMap": {},
6089 "fnMap": {
6090 "0": {
6091 "name": "f",
6092 "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6093 "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6094 }
6095 },
6096 "branchMap": {},
6097 "s": {},
6098 "f": { "0": 2 },
6099 "b": {}
6100 }
6101 }))
6102 .unwrap(),
6103 )
6104 .unwrap();
6105
6106 let coverage =
6107 load_istanbul_coverage(&coverage_path, None, Some(temp.path()), false).unwrap();
6108 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6109 let file_coverage = coverage.get(&canonical_source).unwrap();
6110
6111 assert_eq!(file_coverage.lookup("f", 1, 0), Some(100.0));
6112 }
6113
6114 #[test]
6115 fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
6116 let temp = tempfile::TempDir::new().unwrap();
6117 let source_path = temp.path().join("src/index.ts");
6118 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6119 std::fs::write(&source_path, "export function f(){}").unwrap();
6120
6121 let coverage_path = temp.path().join("coverage/coverage-final.json");
6122 std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
6123 write_single_file_istanbul_fixture(
6124 &coverage_path,
6125 &source_path,
6126 &serde_json::json!({
6127 "0": {
6128 "name": "f",
6129 "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6130 "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6131 }
6132 }),
6133 &serde_json::json!({ "0": 1 }),
6134 );
6135
6136 let coverage = load_istanbul_coverage(
6137 std::path::Path::new("coverage/coverage-final.json"),
6138 None,
6139 Some(temp.path()),
6140 false,
6141 )
6142 .expect("relative path must resolve against project_root");
6143 assert!(
6144 !coverage.files.is_empty(),
6145 "expected coverage to load via project_root resolution, got {} files",
6146 coverage.files.len()
6147 );
6148 }
6149
6150 #[test]
6151 fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
6152 let temp = tempfile::TempDir::new().unwrap();
6153 let source_path = temp.path().join("src/service.ts");
6154 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6155 std::fs::write(&source_path, "export class DataService {}\n").unwrap();
6156
6157 let coverage_path = temp.path().join("coverage-final.json");
6158 write_single_file_istanbul_fixture(
6159 &coverage_path,
6160 &source_path,
6161 &serde_json::json!({
6162 "0": {
6163 "name": "(anonymous_0)",
6164 "decl": {
6165 "start": { "line": 5, "column": 2 },
6166 "end": { "line": 5, "column": 13 }
6167 },
6168 "loc": {
6169 "start": { "line": 5, "column": 14 },
6170 "end": { "line": 11, "column": 3 }
6171 }
6172 },
6173 "1": {
6174 "name": "(anonymous_1)",
6175 "decl": {
6176 "start": { "line": 20, "column": 14 },
6177 "end": { "line": 20, "column": 25 }
6178 },
6179 "loc": {
6180 "start": { "line": 20, "column": 28 },
6181 "end": { "line": 22, "column": 2 }
6182 }
6183 }
6184 }),
6185 &serde_json::json!({
6186 "0": 1,
6187 "1": 0
6188 }),
6189 );
6190
6191 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6192 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6193 let file_coverage = coverage.get(&canonical_source).unwrap();
6194
6195 assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
6196 assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
6197 }
6198
6199 #[test]
6200 fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
6201 let temp = tempfile::TempDir::new().unwrap();
6202 let source_path = temp.path().join("src/handler.ts");
6203 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6204 std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
6205
6206 let coverage_path = temp.path().join("coverage-final.json");
6207 write_single_file_istanbul_fixture(
6208 &coverage_path,
6209 &source_path,
6210 &serde_json::json!({
6211 "0": {
6212 "name": "handleClick",
6213 "line": 40,
6214 "decl": {
6215 "start": { "line": 22, "column": 13 },
6216 "end": { "line": 22, "column": 24 }
6217 },
6218 "loc": {
6219 "start": { "line": 40, "column": 27 },
6220 "end": { "line": 42, "column": 1 }
6221 }
6222 }
6223 }),
6224 &serde_json::json!({
6225 "0": 1
6226 }),
6227 );
6228
6229 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6230 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6231 let file_coverage = coverage.get(&canonical_source).unwrap();
6232
6233 assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
6234 assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
6235 }
6236
6237 #[test]
6238 fn load_istanbul_coverage_indexes_valid_body_start_alias() {
6239 let temp = tempfile::TempDir::new().unwrap();
6240 let source_path = temp.path().join("src/handler.ts");
6241 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6242 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
6243
6244 let coverage_path = temp.path().join("coverage-final.json");
6245 write_single_file_istanbul_fixture(
6246 &coverage_path,
6247 &source_path,
6248 &serde_json::json!({
6249 "0": {
6250 "name": "(anonymous_0)",
6251 "line": 8,
6252 "decl": {
6253 "start": { "line": 8, "column": 14 },
6254 "end": { "line": 8, "column": 25 }
6255 },
6256 "loc": {
6257 "start": { "line": 20, "column": 6 },
6258 "end": { "line": 22, "column": 1 }
6259 }
6260 }
6261 }),
6262 &serde_json::json!({ "0": 1 }),
6263 );
6264
6265 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6266 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6267 let file_coverage = coverage.get(&canonical_source).unwrap();
6268
6269 assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
6270
6271 let mut function = make_fn_complexity(4);
6272 function.name = "handler".to_string();
6273 function.line = 20;
6274 function.col = 6;
6275 let result = istanbul_crap_default(&[function], Some(file_coverage), false);
6276 assert_eq!(result.matched, 1);
6277 assert_eq!(result.total, 1);
6278 assert_eq!(
6279 result.per_function[0].coverage_source,
6280 fallow_output::CoverageSource::Istanbul
6281 );
6282 assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6283 }
6284
6285 #[test]
6292 fn curried_arrows_one_per_line_each_take_their_own_record() {
6293 let temp = tempfile::TempDir::new().unwrap();
6294 let source_path = temp.path().join("src/adjust.ts");
6295 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6296 std::fs::write(
6297 &source_path,
6298 "export const adjust = (base: number) =>\n (factor: number) =>\n (offset: number) =>\n base * factor + offset;\n",
6299 )
6300 .unwrap();
6301
6302 let coverage_path = temp.path().join("coverage-final.json");
6303 write_single_file_istanbul_fixture(
6304 &coverage_path,
6305 &source_path,
6306 &serde_json::json!({
6307 "0": {
6308 "name": "(anonymous_0)",
6309 "line": 2,
6310 "decl": { "start": { "line": 1, "column": 22 }, "end": { "line": 1, "column": 23 } },
6311 "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 26 } }
6312 },
6313 "1": {
6314 "name": "(anonymous_1)",
6315 "line": 3,
6316 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6317 "loc": { "start": { "line": 3, "column": 2 }, "end": { "line": 4, "column": 26 } }
6318 },
6319 "2": {
6320 "name": "(anonymous_2)",
6321 "line": 4,
6322 "decl": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 3 } },
6323 "loc": { "start": { "line": 4, "column": 4 }, "end": { "line": 4, "column": 26 } }
6324 }
6325 }),
6326 &serde_json::json!({ "0": 2, "1": 1, "2": 0 }),
6327 );
6328
6329 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6330 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6331 let file_coverage = coverage.get(&canonical_source).unwrap();
6332
6333 assert_eq!(file_coverage.lookup("adjust", 1, 22), Some(100.0));
6334 assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(100.0));
6335 assert_eq!(file_coverage.lookup("<arrow>", 3, 2), Some(0.0));
6337 }
6338
6339 #[test]
6346 fn a_default_value_keeps_its_own_record_inside_the_enclosing_signature() {
6347 let temp = tempfile::TempDir::new().unwrap();
6348 let source_path = temp.path().join("src/filter.ts");
6349 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6350 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6351
6352 let coverage_path = temp.path().join("coverage-final.json");
6353 write_single_file_istanbul_fixture(
6354 &coverage_path,
6355 &source_path,
6356 &serde_json::json!({
6357 "0": {
6358 "name": "(anonymous_0)",
6359 "line": 173,
6360 "decl": { "start": { "line": 169, "column": 2 }, "end": { "line": 170, "column": 3 } },
6361 "loc": { "start": { "line": 173, "column": 4 }, "end": { "line": 182, "column": 3 } }
6362 },
6363 "1": {
6364 "name": "(anonymous_1)",
6365 "line": 171,
6366 "decl": { "start": { "line": 171, "column": 21 }, "end": { "line": 171, "column": 67 } },
6367 "loc": { "start": { "line": 171, "column": 67 }, "end": { "line": 171, "column": 76 } }
6368 }
6369 }),
6370 &serde_json::json!({ "0": 46, "1": 0 }),
6371 );
6372
6373 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6374 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6375 let file_coverage = coverage.get(&canonical_source).unwrap();
6376
6377 assert_eq!(file_coverage.lookup("<arrow>", 171, 61), Some(0.0));
6380 }
6381
6382 #[test]
6389 fn header_span_abstains_when_another_function_is_declared_inside_it() {
6390 let temp = tempfile::TempDir::new().unwrap();
6391 let source_path = temp.path().join("src/chart.ts");
6392 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6393 std::fs::write(
6394 &source_path,
6395 "export class Chart {\n @Watch(\"data\")\n render(\n rows: number[],\n project = function scale(\n row: number\n ) {\n return row * 2;\n }\n ) {\n return rows.map(project);\n }\n}\n",
6396 )
6397 .unwrap();
6398
6399 let coverage_path = temp.path().join("coverage-final.json");
6400 write_single_file_istanbul_fixture(
6401 &coverage_path,
6402 &source_path,
6403 &serde_json::json!({
6404 "0": {
6405 "name": "(anonymous_0)",
6406 "line": 10,
6407 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6408 "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6409 },
6410 "1": {
6411 "name": "scale",
6412 "line": 7,
6413 "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 28 } },
6414 "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6415 }
6416 }),
6417 &serde_json::json!({ "0": 3, "1": 0 }),
6418 );
6419
6420 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6421 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6422 let coverage = load_istanbul_coverage_for_sources(
6423 &coverage_path,
6424 None,
6425 Some(temp.path()),
6426 Some(&discovered_sources),
6427 false,
6428 )
6429 .unwrap();
6430 let file_coverage = coverage.get(&canonical_source).unwrap();
6431
6432 assert_eq!(file_coverage.lookup("<anonymous>", 6, 6), None);
6435 assert_eq!(file_coverage.lookup("scale", 5, 14), Some(0.0));
6437 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6438 }
6439
6440 #[test]
6444 fn same_named_function_in_signature_does_not_supply_member_coverage() {
6445 let temp = tempfile::TempDir::new().unwrap();
6446 let source_path = temp.path().join("src/chart.ts");
6447 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6448 std::fs::write(
6449 &source_path,
6450 "export class Chart {\n @Watch(\"data\")\n render(\n rows: number[],\n project = function render(\n row: number\n ) {\n return row * 2;\n }\n ) {\n return rows.map(project);\n }\n}\n",
6451 )
6452 .unwrap();
6453
6454 let coverage_path = temp.path().join("coverage-final.json");
6455 write_single_file_istanbul_fixture(
6456 &coverage_path,
6457 &source_path,
6458 &serde_json::json!({
6459 "0": {
6460 "name": "(anonymous_0)",
6461 "line": 10,
6462 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6463 "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
6464 },
6465 "1": {
6466 "name": "render",
6467 "line": 7,
6468 "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 29 } },
6469 "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
6470 }
6471 }),
6472 &serde_json::json!({ "0": 3, "1": 0 }),
6473 );
6474
6475 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6476 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6477 let file_coverage = coverage.get(&canonical_source).unwrap();
6478
6479 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
6480 assert_eq!(file_coverage.lookup("render", 5, 23), Some(0.0));
6481 }
6482
6483 #[test]
6487 fn same_line_same_named_function_in_signature_does_not_supply_member_coverage() {
6488 let temp = tempfile::TempDir::new().unwrap();
6489 let source_path = temp.path().join("src/chart.ts");
6490 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6491 std::fs::write(
6492 &source_path,
6493 "export class Chart {\n @Watch(\"data\") render(x = function render() {}) {}\n}\n",
6494 )
6495 .unwrap();
6496
6497 let coverage_path = temp.path().join("coverage-final.json");
6498 write_single_file_istanbul_fixture(
6499 &coverage_path,
6500 &source_path,
6501 &serde_json::json!({
6502 "0": {
6503 "name": "(anonymous_0)",
6504 "line": 2,
6505 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6506 "loc": { "start": { "line": 2, "column": 52 }, "end": { "line": 2, "column": 54 } }
6507 },
6508 "1": {
6509 "name": "render",
6510 "line": 2,
6511 "decl": { "start": { "line": 2, "column": 39 }, "end": { "line": 2, "column": 45 } },
6512 "loc": { "start": { "line": 2, "column": 48 }, "end": { "line": 2, "column": 50 } }
6513 }
6514 }),
6515 &serde_json::json!({ "0": 3, "1": 0 }),
6516 );
6517
6518 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6519 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6520 let coverage = load_istanbul_coverage_for_sources(
6521 &coverage_path,
6522 None,
6523 Some(temp.path()),
6524 Some(&discovered_sources),
6525 false,
6526 )
6527 .unwrap();
6528 let file_coverage = coverage.get(&canonical_source).unwrap();
6529
6530 assert_eq!(file_coverage.lookup("render", 2, 23), Some(100.0));
6531 assert_eq!(file_coverage.lookup("render", 2, 29), Some(0.0));
6532 }
6533
6534 #[test]
6535 fn named_generator_alias_handles_trivia_and_utf16_columns() {
6536 let temp = tempfile::TempDir::new().unwrap();
6537 let source_path = temp.path().join("src/chart.ts");
6538 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6539 let source_line =
6540 " render(label = \"pi: π, mushroom: 🍄\", x = function/* gap */*render() {}) {}";
6541 std::fs::write(
6542 &source_path,
6543 format!("export class Chart {{\n{source_line}\n}}\n"),
6544 )
6545 .unwrap();
6546
6547 let utf16_column = |byte_column: usize| {
6548 u32::try_from(source_line[..byte_column].encode_utf16().count()).unwrap()
6549 };
6550 let outer_target_column = source_line.find("render(").unwrap() + "render".len();
6551 let syntax_column = source_line.find("function").unwrap();
6552 let name_column = source_line.find("*render").unwrap() + 1;
6553 let inner_body_column = source_line.find("{}").unwrap();
6554 let outer_body_column = source_line.rfind("{}").unwrap();
6555
6556 let coverage_path = temp.path().join("coverage-final.json");
6557 write_single_file_istanbul_fixture(
6558 &coverage_path,
6559 &source_path,
6560 &serde_json::json!({
6561 "0": {
6562 "name": "(anonymous_0)",
6563 "line": 2,
6564 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6565 "loc": {
6566 "start": { "line": 2, "column": utf16_column(outer_body_column) },
6567 "end": { "line": 2, "column": utf16_column(outer_body_column + 2) }
6568 }
6569 },
6570 "1": {
6571 "name": "render",
6572 "line": 2,
6573 "decl": {
6574 "start": { "line": 2, "column": utf16_column(name_column) },
6575 "end": { "line": 2, "column": utf16_column(name_column + "render".len()) }
6576 },
6577 "loc": {
6578 "start": { "line": 2, "column": utf16_column(inner_body_column) },
6579 "end": { "line": 2, "column": utf16_column(inner_body_column + 2) }
6580 }
6581 }
6582 }),
6583 &serde_json::json!({ "0": 3, "1": 0 }),
6584 );
6585
6586 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6587 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6588 let coverage = load_istanbul_coverage_for_sources(
6589 &coverage_path,
6590 None,
6591 Some(temp.path()),
6592 Some(&discovered_sources),
6593 false,
6594 )
6595 .unwrap();
6596 let file_coverage = coverage.get(&canonical_source).unwrap();
6597
6598 assert_eq!(
6599 file_coverage.lookup("render", 2, u32::try_from(outer_target_column).unwrap()),
6600 Some(100.0)
6601 );
6602 assert_eq!(
6603 file_coverage.lookup("render", 2, u32::try_from(syntax_column).unwrap()),
6604 Some(0.0)
6605 );
6606 }
6607
6608 #[test]
6609 fn utf16_index_is_sparse_and_rejects_surrogate_boundaries() {
6610 let ascii_prefix = "a".repeat(4_096);
6611 let source = format!("{ascii_prefix}🍄{}", "b".repeat(4_096));
6612 let index = IstanbulSourceIndex::new(&source, std::path::Path::new("minified.js"));
6613 let line_index = &index.non_ascii_lines[&0];
6614
6615 assert_eq!(line_index.checkpoints.len(), 1);
6616 assert_eq!(
6617 index.byte_position(1, 4_096),
6618 Some(IstanbulPosition::new(1, 4_096))
6619 );
6620 assert_eq!(index.byte_position(1, 4_097), None);
6621 assert_eq!(
6622 index.byte_position(1, 4_098),
6623 Some(IstanbulPosition::new(1, 4_100))
6624 );
6625 assert_eq!(
6626 index.byte_position(1, 8_194),
6627 Some(IstanbulPosition::new(1, 8_196))
6628 );
6629 }
6630
6631 #[test]
6632 fn effective_alias_normalizes_against_its_own_unicode_line() {
6633 let temp = tempfile::TempDir::new().unwrap();
6634 let source_path = temp.path().join("src/render.ts");
6635 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6636 std::fs::write(
6637 &source_path,
6638 "/*🍄*/ const placeholder = 0;\n/*π*/ function render() {}\n",
6639 )
6640 .unwrap();
6641
6642 let coverage_path = temp.path().join("coverage-final.json");
6643 write_single_file_istanbul_fixture(
6644 &coverage_path,
6645 &source_path,
6646 &serde_json::json!({
6647 "0": {
6648 "name": "render",
6649 "line": 1,
6650 "decl": { "start": { "line": 2, "column": 15 }, "end": { "line": 2, "column": 21 } },
6651 "loc": { "start": { "line": 2, "column": 24 }, "end": { "line": 2, "column": 26 } }
6652 }
6653 }),
6654 &serde_json::json!({ "0": 1 }),
6655 );
6656
6657 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6658 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6659 let coverage = load_istanbul_coverage_for_sources(
6660 &coverage_path,
6661 None,
6662 Some(temp.path()),
6663 Some(&discovered_sources),
6664 false,
6665 )
6666 .unwrap();
6667 let function = &coverage.get(&canonical_source).unwrap().functions[0];
6668
6669 assert!(
6670 function
6671 .aliases
6672 .iter()
6673 .any(|alias| { alias.position == IstanbulPosition::new(1, 17) && alias.primary })
6674 );
6675 assert!(
6676 !function
6677 .aliases
6678 .iter()
6679 .any(|alias| alias.position == IstanbulPosition::new(1, 16))
6680 );
6681 }
6682
6683 #[test]
6684 fn stale_utf16_coordinates_are_rejected_with_trusted_source() {
6685 let temp = tempfile::TempDir::new().unwrap();
6686 let source_path = temp.path().join("src/render.ts");
6687 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6688 std::fs::write(&source_path, "export function render() {}\n").unwrap();
6689
6690 let coverage_path = temp.path().join("coverage-final.json");
6691 write_single_file_istanbul_fixture(
6692 &coverage_path,
6693 &source_path,
6694 &serde_json::json!({
6695 "0": {
6696 "name": "render",
6697 "line": 1,
6698 "decl": { "start": { "line": 1, "column": 999 }, "end": { "line": 1, "column": 22 } },
6699 "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6700 }
6701 }),
6702 &serde_json::json!({ "0": 1 }),
6703 );
6704
6705 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6706 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6707 let coverage = load_istanbul_coverage_for_sources(
6708 &coverage_path,
6709 None,
6710 Some(temp.path()),
6711 Some(&discovered_sources),
6712 false,
6713 )
6714 .unwrap();
6715
6716 assert_eq!(
6717 coverage
6718 .get(&canonical_source)
6719 .unwrap()
6720 .lookup("render", 1, 7),
6721 None
6722 );
6723 }
6724
6725 #[test]
6726 fn invalid_optional_coordinates_preserve_valid_declaration_attribution() {
6727 let temp = tempfile::TempDir::new().unwrap();
6728 let source_path = temp.path().join("src/render.ts");
6729 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6730 std::fs::write(&source_path, "export function render() {}\n").unwrap();
6731
6732 let coverage_path = temp.path().join("coverage-final.json");
6733 write_single_file_istanbul_fixture(
6734 &coverage_path,
6735 &source_path,
6736 &serde_json::json!({
6737 "0": {
6738 "name": "render",
6739 "line": 99,
6740 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 99, "column": 999 } },
6741 "loc": { "start": { "line": 1, "column": 999 }, "end": { "line": 99, "column": 999 } }
6742 }
6743 }),
6744 &serde_json::json!({ "0": 1 }),
6745 );
6746
6747 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6748 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
6749 let coverage = load_istanbul_coverage_for_sources(
6750 &coverage_path,
6751 None,
6752 Some(temp.path()),
6753 Some(&discovered_sources),
6754 false,
6755 )
6756 .unwrap();
6757 let file_coverage = coverage.get(&canonical_source).unwrap();
6758 let function = &file_coverage.functions[0];
6759
6760 assert_eq!(file_coverage.lookup("render", 1, 16), Some(100.0));
6761 assert!(function.body_span.is_none());
6762 assert!(function.header_span.is_none());
6763 assert!(
6764 !function
6765 .aliases
6766 .iter()
6767 .any(|alias| { alias.position.line == 99 || alias.position.col == 999 })
6768 );
6769 }
6770
6771 #[test]
6772 fn malformed_source_does_not_supply_named_function_provenance() {
6773 assert!(
6774 !IstanbulSourceIndex::new(
6775 "export function render() {}",
6776 std::path::Path::new("valid.ts"),
6777 )
6778 .named_function_starts
6779 .is_empty()
6780 );
6781 let index = IstanbulSourceIndex::new(
6782 "export function render() {} const broken = ;",
6783 std::path::Path::new("broken.ts"),
6784 );
6785
6786 assert!(index.named_function_starts.is_empty());
6787 }
6788
6789 #[test]
6790 fn javascript_with_jsx_uses_clean_jsx_provenance_parse() {
6791 let index = IstanbulSourceIndex::new(
6792 "export function render() { return <div />; }",
6793 std::path::Path::new("component.js"),
6794 );
6795
6796 assert!(!index.named_function_starts.is_empty());
6797 }
6798
6799 #[test]
6800 fn undiscovered_coverage_path_is_not_loaded_for_source_provenance() {
6801 let temp = tempfile::TempDir::new().unwrap();
6802 let source_path = temp.path().join("src/excluded.ts");
6803 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6804 std::fs::write(&source_path, "export function render() {}\n").unwrap();
6805
6806 let coverage_path = temp.path().join("coverage-final.json");
6807 write_single_file_istanbul_fixture(
6808 &coverage_path,
6809 &source_path,
6810 &serde_json::json!({
6811 "0": {
6812 "name": "render",
6813 "line": 1,
6814 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 22 } },
6815 "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
6816 }
6817 }),
6818 &serde_json::json!({ "0": 1 }),
6819 );
6820
6821 let coverage = load_istanbul_coverage_for_sources(
6822 &coverage_path,
6823 None,
6824 Some(temp.path()),
6825 Some(&rustc_hash::FxHashSet::default()),
6826 false,
6827 )
6828 .unwrap();
6829 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6830 let function = &coverage.get(&canonical_source).unwrap().functions[0];
6831
6832 assert!(
6833 function
6834 .aliases
6835 .iter()
6836 .all(|alias| alias.position != IstanbulPosition::new(1, 7))
6837 );
6838 }
6839
6840 #[test]
6843 fn private_class_member_never_takes_enclosing_coverage() {
6844 let temp = tempfile::TempDir::new().unwrap();
6845 let source_path = temp.path().join("src/vault.js");
6846 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6847 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6848
6849 let coverage_path = temp.path().join("coverage-final.json");
6850 write_single_file_istanbul_fixture(
6851 &coverage_path,
6852 &source_path,
6853 &serde_json::json!({
6854 "0": {
6855 "name": "(anonymous_0)",
6856 "line": 7,
6857 "decl": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 25 } },
6858 "loc": { "start": { "line": 7, "column": 5 }, "end": { "line": 7, "column": 20 } }
6859 }
6860 }),
6861 &serde_json::json!({ "0": 1 }),
6862 );
6863
6864 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6865 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6866 let file_coverage = coverage.get(&canonical_source).unwrap();
6867
6868 let mut private_member = make_fn_complexity(1);
6871 private_member.name = "#wipe".to_string();
6872 private_member.is_private_member = true;
6873 private_member.line = 3;
6874 private_member.col = 9;
6875 let result = istanbul_crap_default(&[private_member], Some(file_coverage), false);
6876 assert_eq!(result.matched, 0);
6877 assert_eq!(
6878 result.per_function[0].coverage_source,
6879 fallow_output::CoverageSource::Estimated
6880 );
6881 assert_eq!(file_coverage.lookup("<arrow>", 1, 24), Some(100.0));
6882 }
6883
6884 #[test]
6885 fn quoted_hash_method_keeps_exact_istanbul_coverage() {
6886 let temp = tempfile::TempDir::new().unwrap();
6887 let source_path = temp.path().join("src/vault.js");
6888 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6889 std::fs::write(&source_path, "class Vault { '#wipe'() {} }\n").unwrap();
6890
6891 let coverage_path = temp.path().join("coverage-final.json");
6892 write_single_file_istanbul_fixture(
6893 &coverage_path,
6894 &source_path,
6895 &serde_json::json!({
6896 "0": {
6897 "name": "#wipe",
6898 "line": 1,
6899 "decl": { "start": { "line": 1, "column": 14 }, "end": { "line": 1, "column": 21 } },
6900 "loc": { "start": { "line": 1, "column": 24 }, "end": { "line": 1, "column": 26 } }
6901 }
6902 }),
6903 &serde_json::json!({ "0": 1 }),
6904 );
6905
6906 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6907 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6908 let file_coverage = coverage.get(&canonical_source).unwrap();
6909
6910 let mut quoted_public_method = make_fn_complexity(1);
6911 quoted_public_method.name = "#wipe".to_string();
6912 quoted_public_method.line = 1;
6913 quoted_public_method.col = 14;
6914 let result = istanbul_crap_default(&[quoted_public_method], Some(file_coverage), false);
6915 assert_eq!(result.matched, 1);
6916 assert_eq!(
6917 result.per_function[0].coverage_source,
6918 fallow_output::CoverageSource::Istanbul
6919 );
6920 assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6921 }
6922
6923 #[test]
6932 fn decorated_member_matches_its_istanbul_header_span() {
6933 let temp = tempfile::TempDir::new().unwrap();
6934 let source_path = temp.path().join("src/users.controller.ts");
6935 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6936 std::fs::write(
6937 &source_path,
6938 "export class UserController {\n @Get(\":id\")\n async findOneWithProfile(\n id: string,\n include: string[]\n ) {\n return this.service.find(id, include);\n }\n}\n",
6939 )
6940 .unwrap();
6941
6942 let coverage_path = temp.path().join("coverage-final.json");
6943 write_single_file_istanbul_fixture(
6944 &coverage_path,
6945 &source_path,
6946 &serde_json::json!({
6947 "0": {
6948 "name": "(anonymous_0)",
6949 "line": 6,
6950 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6951 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
6952 }
6953 }),
6954 &serde_json::json!({ "0": 3 }),
6955 );
6956
6957 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6958 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6959 let file_coverage = coverage.get(&canonical_source).unwrap();
6960
6961 assert_eq!(
6963 file_coverage.lookup("findOneWithProfile", 3, 26),
6964 Some(100.0)
6965 );
6966 }
6967
6968 #[test]
6974 fn established_alias_wins_over_the_signature_that_contains_it() {
6975 let temp = tempfile::TempDir::new().unwrap();
6976 let source_path = temp.path().join("src/chart.ts");
6977 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6978 std::fs::write(
6979 &source_path,
6980 "export class Chart {\n @Watch(\"data\")\n render(\n rows: number[],\n project = (row: number) => row * 2\n ) {\n return rows.map(project);\n }\n}\n",
6981 )
6982 .unwrap();
6983
6984 let coverage_path = temp.path().join("coverage-final.json");
6985 write_single_file_istanbul_fixture(
6986 &coverage_path,
6987 &source_path,
6988 &serde_json::json!({
6989 "0": {
6990 "name": "(anonymous_0)",
6991 "line": 6,
6992 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
6993 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
6994 },
6995 "1": {
6996 "name": "(anonymous_1)",
6997 "line": 5,
6998 "decl": { "start": { "line": 5, "column": 14 }, "end": { "line": 5, "column": 15 } },
6999 "loc": { "start": { "line": 5, "column": 31 }, "end": { "line": 5, "column": 38 } }
7000 }
7001 }),
7002 &serde_json::json!({ "0": 3, "1": 0 }),
7003 );
7004
7005 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7006 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7007 let file_coverage = coverage.get(&canonical_source).unwrap();
7008
7009 assert_eq!(file_coverage.lookup("<arrow>", 5, 14), Some(0.0));
7011 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7013 }
7014
7015 #[test]
7023 fn signature_holding_a_function_abstains_instead_of_crediting_it() {
7024 let temp = tempfile::TempDir::new().unwrap();
7025 let source_path = temp.path().join("src/users.controller.ts");
7026 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7027 std::fs::write(
7028 &source_path,
7029 "export class UserController {\n @Get(\":id\")\n async findOneWithProfile(\n id: string,\n transform = (row: string) => row.trim()\n ) {\n return transform(id);\n }\n}\n",
7030 )
7031 .unwrap();
7032
7033 let coverage_path = temp.path().join("coverage-final.json");
7034 write_single_file_istanbul_fixture(
7035 &coverage_path,
7036 &source_path,
7037 &serde_json::json!({
7038 "0": {
7039 "name": "(anonymous_0)",
7040 "line": 6,
7041 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7042 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7043 },
7044 "1": {
7045 "name": "(anonymous_1)",
7046 "line": 5,
7047 "decl": { "start": { "line": 5, "column": 16 }, "end": { "line": 5, "column": 17 } },
7048 "loc": { "start": { "line": 5, "column": 33 }, "end": { "line": 5, "column": 43 } }
7049 }
7050 }),
7051 &serde_json::json!({ "0": 3, "1": 0 }),
7052 );
7053
7054 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7055 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7056 let file_coverage = coverage.get(&canonical_source).unwrap();
7057
7058 assert_eq!(file_coverage.lookup("findOneWithProfile", 3, 26), None);
7061 assert_eq!(file_coverage.lookup("<arrow>", 5, 16), Some(0.0));
7063 }
7064
7065 #[test]
7066 fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
7067 let temp = tempfile::TempDir::new().unwrap();
7068 let source_path = temp.path().join("src/aliases.ts");
7069 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7070 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7071
7072 let coverage_path = temp.path().join("coverage-final.json");
7073 write_single_file_istanbul_fixture(
7074 &coverage_path,
7075 &source_path,
7076 &serde_json::json!({
7077 "0": {
7078 "name": "(anonymous_0)",
7079 "line": 10,
7080 "decl": {
7081 "start": { "line": 10, "column": 8 },
7082 "end": { "line": 10, "column": 9 }
7083 },
7084 "loc": {
7085 "start": { "line": 12, "column": 8 },
7086 "end": { "line": 13, "column": 1 }
7087 }
7088 }
7089 }),
7090 &serde_json::json!({ "0": 1 }),
7091 );
7092
7093 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7094 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7095 let file_coverage = coverage.get(&canonical_source).unwrap();
7096
7097 assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
7098 }
7099
7100 #[test]
7105 fn curried_arrow_one_liner_resolves_both_arrows() {
7106 let temp = tempfile::TempDir::new().unwrap();
7107 let source_path = temp.path().join("src/nested.ts");
7108 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7109 std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
7110
7111 let coverage_path = temp.path().join("coverage-final.json");
7112 write_single_file_istanbul_fixture(
7113 &coverage_path,
7114 &source_path,
7115 &serde_json::json!({
7116 "0": {
7117 "name": "(anonymous_0)",
7118 "line": 1,
7119 "decl": {
7120 "start": { "line": 1, "column": 22 },
7121 "end": { "line": 1, "column": 23 }
7122 },
7123 "loc": {
7124 "start": { "line": 1, "column": 28 },
7125 "end": { "line": 1, "column": 38 }
7126 }
7127 },
7128 "1": {
7129 "name": "(anonymous_1)",
7130 "line": 1,
7131 "decl": {
7132 "start": { "line": 1, "column": 28 },
7133 "end": { "line": 1, "column": 29 }
7134 },
7135 "loc": {
7136 "start": { "line": 1, "column": 34 },
7137 "end": { "line": 1, "column": 38 }
7138 }
7139 }
7140 }),
7141 &serde_json::json!({ "0": 1, "1": 0 }),
7142 );
7143
7144 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7145 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7146 let file_coverage = coverage.get(&canonical_source).unwrap();
7147
7148 assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
7149 assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
7150 }
7151
7152 #[test]
7173 fn nested_function_statements_do_not_lower_the_outer_function() {
7174 let temp = tempfile::TempDir::new().unwrap();
7175 let source_path = temp.path().join("src/nested.js");
7176 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7177 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7178
7179 let coverage_path = temp.path().join("coverage-final.json");
7180 write_single_file_istanbul_fixture_with_statements(
7181 &coverage_path,
7182 &source_path,
7183 &nested_return_fn_map(),
7184 &serde_json::json!({ "0": 1, "1": 0 }),
7185 &nested_return_statement_map(),
7186 &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 1 }),
7187 );
7188
7189 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7190 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7191 let file_coverage = coverage.get(&canonical_source).unwrap();
7192
7193 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7194 assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7195 }
7196
7197 #[test]
7201 fn module_scope_statements_belong_to_no_function() {
7202 let temp = tempfile::TempDir::new().unwrap();
7203 let source_path = temp.path().join("src/nested.js");
7204 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7205 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7206
7207 let coverage_path = temp.path().join("coverage-final.json");
7208 write_single_file_istanbul_fixture_with_statements(
7209 &coverage_path,
7210 &source_path,
7211 &nested_return_fn_map(),
7212 &serde_json::json!({ "0": 1, "1": 0 }),
7213 &nested_return_statement_map(),
7214 &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 0 }),
7215 );
7216
7217 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7218 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7219 let file_coverage = coverage.get(&canonical_source).unwrap();
7220
7221 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7222 assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7223 }
7224
7225 #[test]
7243 fn an_outer_function_with_only_a_nested_body_falls_back_to_the_hit_count() {
7244 let temp = tempfile::TempDir::new().unwrap();
7245 let source_path = temp.path().join("src/decl-nested.js");
7246 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7247 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7248
7249 let coverage_path = temp.path().join("coverage-final.json");
7250 write_single_file_istanbul_fixture_with_statements(
7251 &coverage_path,
7252 &source_path,
7253 &serde_json::json!({
7254 "0": {
7255 "name": "outer",
7256 "line": 1,
7257 "decl": {
7258 "start": { "line": 1, "column": 9 },
7259 "end": { "line": 1, "column": 14 }
7260 },
7261 "loc": {
7262 "start": { "line": 1, "column": 17 },
7263 "end": { "line": 6, "column": 1 }
7264 }
7265 },
7266 "1": {
7267 "name": "inner",
7268 "line": 2,
7269 "decl": {
7270 "start": { "line": 2, "column": 11 },
7271 "end": { "line": 2, "column": 16 }
7272 },
7273 "loc": {
7274 "start": { "line": 2, "column": 23 },
7275 "end": { "line": 5, "column": 3 }
7276 }
7277 }
7278 }),
7279 &serde_json::json!({ "0": 1, "1": 0 }),
7280 &serde_json::json!({
7281 "0": {
7282 "start": { "line": 3, "column": 4 },
7283 "end": { "line": 3, "column": 27 }
7284 },
7285 "1": {
7286 "start": { "line": 3, "column": 14 },
7287 "end": { "line": 3, "column": 27 }
7288 },
7289 "2": {
7290 "start": { "line": 4, "column": 4 },
7291 "end": { "line": 4, "column": 16 }
7292 },
7293 "3": {
7294 "start": { "line": 8, "column": 0 },
7295 "end": { "line": 8, "column": 27 }
7296 }
7297 }),
7298 &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1 }),
7299 );
7300
7301 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7302 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7303 let file_coverage = coverage.get(&canonical_source).unwrap();
7304
7305 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7306 assert_eq!(file_coverage.lookup("inner", 2, 11), Some(0.0));
7307 }
7308
7309 #[test]
7314 fn sibling_functions_keep_independent_statement_coverage() {
7315 let temp = tempfile::TempDir::new().unwrap();
7316 let source_path = temp.path().join("src/sibling.js");
7317 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7318 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7319
7320 let coverage_path = temp.path().join("coverage-final.json");
7321 write_single_file_istanbul_fixture_with_statements(
7322 &coverage_path,
7323 &source_path,
7324 &serde_json::json!({
7325 "0": {
7326 "name": "inner",
7327 "line": 1,
7328 "decl": {
7329 "start": { "line": 1, "column": 9 },
7330 "end": { "line": 1, "column": 14 }
7331 },
7332 "loc": {
7333 "start": { "line": 1, "column": 21 },
7334 "end": { "line": 4, "column": 1 }
7335 }
7336 },
7337 "1": {
7338 "name": "outer",
7339 "line": 6,
7340 "decl": {
7341 "start": { "line": 6, "column": 9 },
7342 "end": { "line": 6, "column": 14 }
7343 },
7344 "loc": {
7345 "start": { "line": 6, "column": 17 },
7346 "end": { "line": 8, "column": 1 }
7347 }
7348 }
7349 }),
7350 &serde_json::json!({ "0": 0, "1": 1 }),
7351 &serde_json::json!({
7352 "0": {
7353 "start": { "line": 2, "column": 2 },
7354 "end": { "line": 2, "column": 25 }
7355 },
7356 "1": {
7357 "start": { "line": 2, "column": 12 },
7358 "end": { "line": 2, "column": 25 }
7359 },
7360 "2": {
7361 "start": { "line": 3, "column": 2 },
7362 "end": { "line": 3, "column": 14 }
7363 },
7364 "3": {
7365 "start": { "line": 7, "column": 2 },
7366 "end": { "line": 7, "column": 15 }
7367 },
7368 "4": {
7369 "start": { "line": 10, "column": 0 },
7370 "end": { "line": 10, "column": 27 }
7371 }
7372 }),
7373 &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1, "4": 1 }),
7374 );
7375
7376 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7377 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7378 let file_coverage = coverage.get(&canonical_source).unwrap();
7379
7380 assert_eq!(file_coverage.lookup("outer", 6, 9), Some(100.0));
7381 assert_eq!(file_coverage.lookup("inner", 1, 9), Some(0.0));
7382 }
7383
7384 #[test]
7390 fn curried_arrow_statements_belong_to_the_innermost_arrow() {
7391 let temp = tempfile::TempDir::new().unwrap();
7392 let source_path = temp.path().join("src/curried.js");
7393 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7394 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7395
7396 let coverage_path = temp.path().join("coverage-final.json");
7397 write_single_file_istanbul_fixture_with_statements(
7398 &coverage_path,
7399 &source_path,
7400 &serde_json::json!({
7401 "0": {
7402 "name": "(anonymous_0)",
7403 "line": 1,
7404 "decl": {
7405 "start": { "line": 1, "column": 12 },
7406 "end": { "line": 1, "column": 13 }
7407 },
7408 "loc": {
7409 "start": { "line": 1, "column": 19 },
7410 "end": { "line": 1, "column": 31 }
7411 }
7412 },
7413 "1": {
7414 "name": "(anonymous_1)",
7415 "line": 1,
7416 "decl": {
7417 "start": { "line": 1, "column": 19 },
7418 "end": { "line": 1, "column": 20 }
7419 },
7420 "loc": {
7421 "start": { "line": 1, "column": 26 },
7422 "end": { "line": 1, "column": 31 }
7423 }
7424 }
7425 }),
7426 &serde_json::json!({ "0": 1, "1": 0 }),
7427 &serde_json::json!({
7428 "0": {
7429 "start": { "line": 1, "column": 12 },
7430 "end": { "line": 1, "column": 31 }
7431 },
7432 "1": {
7433 "start": { "line": 1, "column": 19 },
7434 "end": { "line": 1, "column": 31 }
7435 },
7436 "2": {
7437 "start": { "line": 1, "column": 26 },
7438 "end": { "line": 1, "column": 31 }
7439 },
7440 "3": {
7441 "start": { "line": 3, "column": 0 },
7442 "end": { "line": 3, "column": 25 }
7443 }
7444 }),
7445 &serde_json::json!({ "0": 1, "1": 1, "2": 0, "3": 1 }),
7446 );
7447
7448 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7449 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7450 let file_coverage = coverage.get(&canonical_source).unwrap();
7451
7452 assert_eq!(file_coverage.lookup("add", 1, 12), Some(100.0));
7453 assert_eq!(file_coverage.lookup("<arrow>", 1, 19), Some(0.0));
7454 }
7455
7456 fn nested_return_fn_map() -> serde_json::Value {
7460 serde_json::json!({
7461 "0": {
7462 "name": "outer",
7463 "line": 1,
7464 "decl": {
7465 "start": { "line": 1, "column": 9 },
7466 "end": { "line": 1, "column": 14 }
7467 },
7468 "loc": {
7469 "start": { "line": 1, "column": 17 },
7470 "end": { "line": 6, "column": 1 }
7471 }
7472 },
7473 "1": {
7474 "name": "inner",
7475 "line": 2,
7476 "decl": {
7477 "start": { "line": 2, "column": 18 },
7478 "end": { "line": 2, "column": 23 }
7479 },
7480 "loc": {
7481 "start": { "line": 2, "column": 30 },
7482 "end": { "line": 5, "column": 3 }
7483 }
7484 }
7485 })
7486 }
7487
7488 fn nested_return_statement_map() -> serde_json::Value {
7489 serde_json::json!({
7490 "0": {
7491 "start": { "line": 2, "column": 2 },
7492 "end": { "line": 5, "column": 4 }
7493 },
7494 "1": {
7495 "start": { "line": 3, "column": 4 },
7496 "end": { "line": 3, "column": 27 }
7497 },
7498 "2": {
7499 "start": { "line": 3, "column": 14 },
7500 "end": { "line": 3, "column": 27 }
7501 },
7502 "3": {
7503 "start": { "line": 4, "column": 4 },
7504 "end": { "line": 4, "column": 16 }
7505 },
7506 "4": {
7507 "start": { "line": 8, "column": 0 },
7508 "end": { "line": 8, "column": 27 }
7509 }
7510 })
7511 }
7512
7513 #[test]
7517 fn curried_arrow_multiline_hoc_resolves_both_arrows() {
7518 let temp = tempfile::TempDir::new().unwrap();
7519 let source_path = temp.path().join("src/with-auth.tsx");
7520 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7521 std::fs::write(
7522 &source_path,
7523 "export const withAuth = (Component) =>\n (props) => {\n return Component(props);\n };\n",
7524 )
7525 .unwrap();
7526
7527 let coverage_path = temp.path().join("coverage-final.json");
7528 write_single_file_istanbul_fixture(
7529 &coverage_path,
7530 &source_path,
7531 &serde_json::json!({
7532 "0": {
7533 "name": "(anonymous_0)",
7534 "line": 2,
7535 "decl": {
7536 "start": { "line": 1, "column": 24 },
7537 "end": { "line": 1, "column": 25 }
7538 },
7539 "loc": {
7540 "start": { "line": 2, "column": 2 },
7541 "end": { "line": 4, "column": 3 }
7542 }
7543 },
7544 "1": {
7545 "name": "(anonymous_1)",
7546 "line": 2,
7547 "decl": {
7548 "start": { "line": 2, "column": 2 },
7549 "end": { "line": 2, "column": 3 }
7550 },
7551 "loc": {
7552 "start": { "line": 2, "column": 13 },
7553 "end": { "line": 4, "column": 3 }
7554 }
7555 }
7556 }),
7557 &serde_json::json!({ "0": 1, "1": 0 }),
7558 );
7559
7560 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7561 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7562 let file_coverage = coverage.get(&canonical_source).unwrap();
7563
7564 assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
7565 assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
7566 }
7567
7568 #[test]
7571 fn curried_arrow_depth_three_chain_resolves_every_arrow() {
7572 let temp = tempfile::TempDir::new().unwrap();
7573 let source_path = temp.path().join("src/logger.ts");
7574 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7575 std::fs::write(
7576 &source_path,
7577 "export const logger = (store) => (next) => (action) => {\n return next(action);\n};\n",
7578 )
7579 .unwrap();
7580
7581 let coverage_path = temp.path().join("coverage-final.json");
7582 write_single_file_istanbul_fixture(
7583 &coverage_path,
7584 &source_path,
7585 &serde_json::json!({
7586 "0": {
7587 "name": "(anonymous_0)",
7588 "line": 1,
7589 "decl": {
7590 "start": { "line": 1, "column": 22 },
7591 "end": { "line": 1, "column": 23 }
7592 },
7593 "loc": {
7594 "start": { "line": 1, "column": 33 },
7595 "end": { "line": 3, "column": 1 }
7596 }
7597 },
7598 "1": {
7599 "name": "(anonymous_1)",
7600 "line": 1,
7601 "decl": {
7602 "start": { "line": 1, "column": 33 },
7603 "end": { "line": 1, "column": 34 }
7604 },
7605 "loc": {
7606 "start": { "line": 1, "column": 43 },
7607 "end": { "line": 3, "column": 1 }
7608 }
7609 },
7610 "2": {
7611 "name": "(anonymous_2)",
7612 "line": 1,
7613 "decl": {
7614 "start": { "line": 1, "column": 43 },
7615 "end": { "line": 1, "column": 44 }
7616 },
7617 "loc": {
7618 "start": { "line": 1, "column": 55 },
7619 "end": { "line": 3, "column": 1 }
7620 }
7621 }
7622 }),
7623 &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
7624 );
7625
7626 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7627 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7628 let file_coverage = coverage.get(&canonical_source).unwrap();
7629
7630 assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
7631 assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
7632 assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
7633 }
7634
7635 #[test]
7637 fn curried_class_property_arrow_resolves_both_arrows() {
7638 let temp = tempfile::TempDir::new().unwrap();
7639 let source_path = temp.path().join("src/store.ts");
7640 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7641 std::fs::write(
7642 &source_path,
7643 "export class Store {\n handle = (event) => (payload) => {\n return payload;\n };\n}\n",
7644 )
7645 .unwrap();
7646
7647 let coverage_path = temp.path().join("coverage-final.json");
7648 write_single_file_istanbul_fixture(
7649 &coverage_path,
7650 &source_path,
7651 &serde_json::json!({
7652 "0": {
7653 "name": "(anonymous_0)",
7654 "line": 2,
7655 "decl": {
7656 "start": { "line": 2, "column": 11 },
7657 "end": { "line": 2, "column": 12 }
7658 },
7659 "loc": {
7660 "start": { "line": 2, "column": 22 },
7661 "end": { "line": 4, "column": 3 }
7662 }
7663 },
7664 "1": {
7665 "name": "(anonymous_1)",
7666 "line": 2,
7667 "decl": {
7668 "start": { "line": 2, "column": 22 },
7669 "end": { "line": 2, "column": 23 }
7670 },
7671 "loc": {
7672 "start": { "line": 2, "column": 35 },
7673 "end": { "line": 4, "column": 3 }
7674 }
7675 }
7676 }),
7677 &serde_json::json!({ "0": 1, "1": 0 }),
7678 );
7679
7680 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7681 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7682 let file_coverage = coverage.get(&canonical_source).unwrap();
7683
7684 assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
7685 assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
7686 }
7687
7688 #[test]
7692 fn anonymous_sibling_tie_outside_every_body_abstains() {
7693 let temp = tempfile::TempDir::new().unwrap();
7694 let source_path = temp.path().join("src/handlers.ts");
7695 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7696 std::fs::write(
7697 &source_path,
7698 "export const handlers = {\n a: () => true,\n\n b: () => false,\n};\n",
7699 )
7700 .unwrap();
7701
7702 let coverage_path = temp.path().join("coverage-final.json");
7703 write_single_file_istanbul_fixture(
7704 &coverage_path,
7705 &source_path,
7706 &serde_json::json!({
7707 "0": {
7708 "name": "(anonymous_0)",
7709 "line": 2,
7710 "decl": {
7711 "start": { "line": 2, "column": 5 },
7712 "end": { "line": 2, "column": 6 }
7713 },
7714 "loc": {
7715 "start": { "line": 2, "column": 11 },
7716 "end": { "line": 2, "column": 15 }
7717 }
7718 },
7719 "1": {
7720 "name": "(anonymous_1)",
7721 "line": 4,
7722 "decl": {
7723 "start": { "line": 4, "column": 5 },
7724 "end": { "line": 4, "column": 6 }
7725 },
7726 "loc": {
7727 "start": { "line": 4, "column": 11 },
7728 "end": { "line": 4, "column": 16 }
7729 }
7730 }
7731 }),
7732 &serde_json::json!({ "0": 1, "1": 0 }),
7733 );
7734
7735 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7736 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7737 let file_coverage = coverage.get(&canonical_source).unwrap();
7738
7739 assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
7740 assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
7741 assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
7742 }
7743
7744 #[test]
7750 fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
7751 let temp = tempfile::TempDir::new().unwrap();
7752 let source_path = temp.path().join("src/nested.ts");
7753 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7754 std::fs::write(
7755 &source_path,
7756 "export const o = function () { const f = () => { return 1;\n }; return f; };\n",
7757 )
7758 .unwrap();
7759
7760 let coverage_path = temp.path().join("coverage-final.json");
7761 write_single_file_istanbul_fixture(
7762 &coverage_path,
7763 &source_path,
7764 &serde_json::json!({
7765 "0": {
7766 "name": "(anonymous_0)",
7767 "line": 1,
7768 "decl": {
7769 "start": { "line": 1, "column": 17 },
7770 "end": { "line": 1, "column": 18 }
7771 },
7772 "loc": {
7773 "start": { "line": 1, "column": 29 },
7774 "end": { "line": 2, "column": 53 }
7775 }
7776 },
7777 "1": {
7778 "name": "(anonymous_1)",
7779 "line": 1,
7780 "decl": {
7781 "start": { "line": 1, "column": 41 },
7782 "end": { "line": 1, "column": 42 }
7783 },
7784 "loc": {
7785 "start": { "line": 1, "column": 47 },
7786 "end": { "line": 2, "column": 40 }
7787 }
7788 }
7789 }),
7790 &serde_json::json!({ "0": 1, "1": 0 }),
7791 );
7792
7793 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7794 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7795 let file_coverage = coverage.get(&canonical_source).unwrap();
7796
7797 assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
7798 }
7799
7800 #[test]
7803 fn anonymous_tie_rejects_incomparable_containing_spans() {
7804 let file_coverage = IstanbulFileCoverage::new(
7805 vec![
7806 IstanbulFunctionCoverage {
7807 name: "(anonymous_0)".to_string(),
7808 coverage_pct: 100.0,
7809 aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
7810 decl_start: IstanbulPosition::new(10, 8),
7811 header_holds_other_fn: false,
7812 header_span: None,
7813 body_span: Some(body_span((10, 14), (12, 30))),
7814 },
7815 IstanbulFunctionCoverage {
7816 name: "(anonymous_1)".to_string(),
7817 coverage_pct: 0.0,
7818 aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
7819 decl_start: IstanbulPosition::new(10, 20),
7820 header_holds_other_fn: false,
7821 header_span: None,
7822 body_span: Some(body_span((11, 0), (14, 0))),
7823 },
7824 ],
7825 false,
7826 );
7827
7828 assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
7829 }
7830
7831 #[test]
7836 fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
7837 let file_coverage = IstanbulFileCoverage::new(
7838 vec![
7839 IstanbulFunctionCoverage {
7840 name: "(anonymous_0)".to_string(),
7841 coverage_pct: 100.0,
7842 aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
7843 decl_start: IstanbulPosition::new(4, 11),
7844 header_holds_other_fn: false,
7845 header_span: None,
7846 body_span: Some(body_span((4, 11), (4, 23))),
7847 },
7848 IstanbulFunctionCoverage {
7849 name: "(anonymous_1)".to_string(),
7850 coverage_pct: 0.0,
7851 aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
7852 decl_start: IstanbulPosition::new(4, 11),
7853 header_holds_other_fn: false,
7854 header_span: None,
7855 body_span: Some(body_span((4, 18), (4, 23))),
7856 },
7857 ],
7858 false,
7859 );
7860
7861 assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
7862 assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
7863 }
7864
7865 #[test]
7866 fn colliding_anonymous_alias_uses_unique_safe_header_span() {
7867 let file_coverage = IstanbulFileCoverage::new(
7868 vec![
7869 IstanbulFunctionCoverage {
7870 name: "(anonymous_0)".to_string(),
7871 coverage_pct: 100.0,
7872 aliases: vec![primary_alias(1, 0), secondary_alias(3, 4)],
7873 decl_start: IstanbulPosition::new(1, 0),
7874 header_holds_other_fn: false,
7875 header_span: Some(body_span((1, 0), (5, 0))),
7876 body_span: Some(body_span((5, 0), (8, 0))),
7877 },
7878 IstanbulFunctionCoverage {
7879 name: "(anonymous_1)".to_string(),
7880 coverage_pct: 0.0,
7881 aliases: vec![primary_alias(10, 0), secondary_alias(3, 4)],
7882 decl_start: IstanbulPosition::new(10, 0),
7883 header_holds_other_fn: false,
7884 header_span: None,
7885 body_span: Some(body_span((10, 0), (12, 0))),
7886 },
7887 ],
7888 false,
7889 );
7890
7891 assert_eq!(file_coverage.lookup("<arrow>", 3, 4), Some(100.0));
7892 }
7893
7894 #[test]
7897 fn colliding_secondary_aliases_abstain_at_shared_position() {
7898 let file_coverage = IstanbulFileCoverage::new(
7899 vec![
7900 IstanbulFunctionCoverage {
7901 name: "(anonymous_0)".to_string(),
7902 coverage_pct: 100.0,
7903 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
7904 decl_start: IstanbulPosition::new(10, 0),
7905 header_holds_other_fn: false,
7906 header_span: None,
7907 body_span: Some(body_span((12, 4), (20, 0))),
7908 },
7909 IstanbulFunctionCoverage {
7910 name: "(anonymous_1)".to_string(),
7911 coverage_pct: 0.0,
7912 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
7913 decl_start: IstanbulPosition::new(11, 0),
7914 header_holds_other_fn: false,
7915 header_span: None,
7916 body_span: Some(body_span((12, 4), (18, 0))),
7917 },
7918 ],
7919 false,
7920 );
7921
7922 assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
7923 assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
7924 assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
7925 }
7926
7927 #[test]
7928 fn colliding_named_secondary_aliases_abstain_at_shared_position() {
7929 let file_coverage = IstanbulFileCoverage::new(
7930 vec![
7931 IstanbulFunctionCoverage {
7932 name: "handler".to_string(),
7933 coverage_pct: 100.0,
7934 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
7935 decl_start: IstanbulPosition::new(10, 0),
7936 header_holds_other_fn: false,
7937 header_span: None,
7938 body_span: Some(body_span((12, 4), (20, 0))),
7939 },
7940 IstanbulFunctionCoverage {
7941 name: "handler".to_string(),
7942 coverage_pct: 0.0,
7943 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
7944 decl_start: IstanbulPosition::new(11, 0),
7945 header_holds_other_fn: false,
7946 header_span: None,
7947 body_span: Some(body_span((12, 4), (18, 0))),
7948 },
7949 ],
7950 false,
7951 );
7952
7953 assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
7954 assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
7955 assert!(file_coverage.lookup("handler", 12, 4).is_none());
7956 }
7957
7958 #[test]
7959 fn invalid_body_location_does_not_create_an_alias() {
7960 let temp = tempfile::TempDir::new().unwrap();
7961 let source_path = temp.path().join("src/invalid-location.ts");
7962 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7963 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7964
7965 let coverage_path = temp.path().join("coverage-final.json");
7966 write_single_file_istanbul_fixture(
7967 &coverage_path,
7968 &source_path,
7969 &serde_json::json!({
7970 "0": {
7971 "name": "(anonymous_0)",
7972 "line": 8,
7973 "decl": {
7974 "start": { "line": 8, "column": 14 },
7975 "end": { "line": 8, "column": 25 }
7976 },
7977 "loc": {
7978 "start": { "line": 22, "column": 1 },
7979 "end": { "line": 20, "column": 6 }
7980 }
7981 }
7982 }),
7983 &serde_json::json!({ "0": 1 }),
7984 );
7985
7986 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7987 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7988 let file_coverage = coverage.get(&canonical_source).unwrap();
7989
7990 assert!(file_coverage.lookup("handler", 22, 1).is_none());
7991 }
7992
7993 #[test]
7994 fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
7995 let temp = tempfile::TempDir::new().unwrap();
7996 let source_path = temp.path().join("src/actor.ts");
7997 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7998 std::fs::write(
7999 &source_path,
8000 "export const elementsFrom = async (\n locator: AnyLocator,\n options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n return [];\n};\n",
8001 )
8002 .unwrap();
8003
8004 let coverage_path = temp.path().join("coverage-final.json");
8005 write_single_file_istanbul_fixture(
8006 &coverage_path,
8007 &source_path,
8008 &serde_json::json!({
8009 "0": {
8010 "name": "(anonymous_0)",
8011 "line": 4,
8012 "decl": {
8013 "start": { "line": 1, "column": 28 },
8014 "end": { "line": 4, "column": 26 }
8015 },
8016 "loc": {
8017 "start": { "line": 4, "column": 27 },
8018 "end": { "line": 6, "column": 1 }
8019 }
8020 }
8021 }),
8022 &serde_json::json!({
8023 "0": 642
8024 }),
8025 );
8026
8027 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8028 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8029 let file_coverage = coverage.get(&canonical_source).unwrap();
8030
8031 assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
8032 }
8033
8034 #[test]
8035 fn istanbul_lookup_exact_match() {
8036 let mut functions = rustc_hash::FxHashMap::default();
8037 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8038 let fc = test_istanbul_file_coverage(functions, false);
8039 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
8040 }
8041
8042 #[test]
8043 fn istanbul_lookup_fuzzy_match_within_offset() {
8044 let mut functions = rustc_hash::FxHashMap::default();
8045 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8046 let fc = test_istanbul_file_coverage(functions, false);
8047 assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8048 assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8049 }
8050
8051 #[test]
8052 fn istanbul_lookup_fuzzy_match_outside_offset() {
8053 let mut functions = rustc_hash::FxHashMap::default();
8054 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8055 let fc = test_istanbul_file_coverage(functions, false);
8056 assert!(fc.lookup("handleClick", 13, 0).is_none());
8057 }
8058
8059 #[test]
8060 fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
8061 let mut functions = rustc_hash::FxHashMap::default();
8062 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8063 let fc = test_istanbul_file_coverage(functions, true);
8064 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8065 }
8066
8067 #[test]
8068 fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
8069 let mut functions = rustc_hash::FxHashMap::default();
8070 functions.insert(("handleClick".to_string(), 29, 16), 72.0);
8071 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8072 let fc = test_istanbul_file_coverage(functions, true);
8073 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8074 }
8075
8076 #[test]
8077 fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
8078 let mut functions = rustc_hash::FxHashMap::default();
8079 functions.insert(("render".to_string(), 29, 0), 72.0);
8080 functions.insert(("render".to_string(), 80, 0), 10.0);
8081 let fc = test_istanbul_file_coverage(functions, true);
8082 assert!(fc.lookup("render", 10, 0).is_none());
8083 }
8084
8085 #[test]
8086 fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
8087 let mut functions = rustc_hash::FxHashMap::default();
8088 functions.insert(("render".to_string(), 11, 0), 72.0);
8089 functions.insert(("render".to_string(), 80, 0), 10.0);
8090 let fc = test_istanbul_file_coverage(functions, true);
8091 assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8092 }
8093
8094 #[test]
8095 fn istanbul_lookup_name_mismatch() {
8096 let mut functions = rustc_hash::FxHashMap::default();
8097 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8098 let fc = test_istanbul_file_coverage(functions, false);
8099 assert!(fc.lookup("handleSubmit", 10, 0).is_none());
8100 }
8101
8102 #[test]
8103 fn istanbul_lookup_empty() {
8104 let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
8105 assert!(fc.lookup("anything", 1, 0).is_none());
8106 }
8107
8108 #[test]
8109 fn istanbul_lookup_fuzzy_picks_closest() {
8110 let mut functions = rustc_hash::FxHashMap::default();
8111 functions.insert(("render".to_string(), 8, 0), 60.0);
8112 functions.insert(("render".to_string(), 12, 0), 90.0);
8113 let fc = test_istanbul_file_coverage(functions, false);
8114 let result = fc.lookup("render", 10, 0);
8115 assert!(result.is_some());
8116 let pct = result.unwrap();
8117 assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
8118 }
8119
8120 #[test]
8121 fn istanbul_lookup_anonymous_fallback_single_candidate() {
8122 let mut functions = rustc_hash::FxHashMap::default();
8123 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8124 let fc = test_istanbul_file_coverage(functions, false);
8125 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8126 assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8127 }
8128
8129 #[test]
8130 fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
8131 let mut functions = rustc_hash::FxHashMap::default();
8132 functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
8133 let fc = test_istanbul_file_coverage(functions, false);
8134
8135 assert!(fc.lookup("declaredHelper", 3, 0).is_none());
8136 }
8137
8138 #[test]
8139 fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
8140 let mut functions = rustc_hash::FxHashMap::default();
8141 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8142 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8143 let fc = test_istanbul_file_coverage(functions, false);
8144 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8145 }
8146
8147 #[test]
8148 fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
8149 let mut functions = rustc_hash::FxHashMap::default();
8150 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);
8153 assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
8154 assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
8155 }
8156
8157 #[test]
8158 fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
8159 let mut functions = rustc_hash::FxHashMap::default();
8160 functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
8161 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8162 let fc = test_istanbul_file_coverage(functions, false);
8163 assert!(fc.lookup("myHandler", 28, 0).is_none());
8164 }
8165
8166 #[test]
8167 fn istanbul_lookup_anonymous_fallback_outside_offset() {
8168 let mut functions = rustc_hash::FxHashMap::default();
8169 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8170 let fc = test_istanbul_file_coverage(functions, false);
8171 assert!(fc.lookup("myHandler", 31, 0).is_none());
8172 }
8173
8174 #[test]
8175 fn istanbul_lookup_named_match_beats_nearby_anonymous() {
8176 let mut functions = rustc_hash::FxHashMap::default();
8177 functions.insert(("handleClick".to_string(), 10, 0), 90.0);
8178 functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
8179 let fc = test_istanbul_file_coverage(functions, false);
8180 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
8181 }
8182
8183 #[test]
8184 fn build_test_refs_empty() {
8185 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
8186 let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
8187 let refs = build_test_referenced_exports(&exports, StaticTestCoverage::new(&graph));
8188 assert!(refs.is_empty());
8189 }
8190
8191 #[test]
8192 fn istanbul_crap_empty_complexity() {
8193 let result = istanbul_crap_default(&[], None, false);
8194 assert!((result.max_crap).abs() < f64::EPSILON);
8195 assert_eq!(result.signals.above, 0);
8196 assert_eq!(result.matched, 0);
8197 assert_eq!(result.total, 0);
8198 }
8199
8200 #[test]
8201 fn istanbul_crap_match_statistics() {
8202 let funcs = vec![make_fn_complexity(5), {
8203 let mut f = make_fn_complexity(3);
8204 f.name = "other_fn".into();
8205 f.line = 10;
8206 f
8207 }];
8208 let mut functions = rustc_hash::FxHashMap::default();
8209 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
8210 let file_cov = test_istanbul_file_coverage(functions, false);
8211 let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
8212 assert_eq!(result.matched, 1);
8213 assert_eq!(result.total, 2);
8214 }
8215
8216 #[test]
8217 fn estimated_crap_multiple_functions_mixed_coverage() {
8218 let funcs = vec![
8219 make_fn_complexity(10), {
8221 let mut f = make_fn_complexity(3);
8222 f.name = "helper".into();
8223 f.line = 20;
8224 f
8225 },
8226 ];
8227 let mut refs = rustc_hash::FxHashSet::default();
8228 refs.insert("test_fn".to_string());
8229 let result = estimated_crap_default(
8230 &funcs,
8231 &refs,
8232 true,
8233 fallow_output::CoverageSource::Estimated,
8234 );
8235 let (max, above) = (result.max_crap, result.signals.above);
8236 assert!(max > 10.0);
8237 assert_eq!(above, 0);
8238 }
8239}