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::test_reach::build_test_referenced_exports;
8use super::threshold_overrides::ThresholdOverrideResolver;
9
10const MAX_FUNCTION_EVIDENCE: usize = 3;
11
12pub struct FileScoreOutput {
14 pub(crate) scores: Vec<FileHealthScore>,
15 pub(crate) coverage: CoverageGapData,
17 pub(crate) circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
19 pub(crate) top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
21 pub(crate) crap_target_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
23 pub(crate) entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
25 pub(crate) value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
27 pub(crate) unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
29 pub(crate) cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
31 pub(crate) direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
33 pub(crate) analysis_counts: crate::vital_signs::AnalysisCounts,
35 pub(crate) prop_drilling_chains: Vec<fallow_types::output_dead_code::PropDrillingChainFinding>,
40 pub(crate) render_fan_in: Option<fallow_types::results::RenderFanInMetric>,
46 pub(crate) analysis_snapshot: AnalysisCountsSnapshot,
50 pub(crate) istanbul_matched: usize,
52 pub(crate) istanbul_total: usize,
53 pub(crate) istanbul_files_joined: usize,
57 pub(crate) istanbul_files_total: usize,
59 pub(crate) coverage_input_format: Option<fallow_output::CoverageInputFormat>,
61 pub(crate) per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
65 pub(crate) template_inherit_provenance:
72 rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
73 pub(crate) static_links: Option<crate::change_coupling::StaticLinkIndex>,
77}
78
79struct FileScoreOutputParts<'a> {
80 graph: &'a fallow_graph::graph::ModuleGraph,
81 with_static_links: bool,
82 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
83 results: &'a crate::results::AnalysisResults,
84 scores: Vec<FileHealthScore>,
85 coverage: CoverageGapData,
86 circular_files: rustc_hash::FxHashSet<std::path::PathBuf>,
87 top_complex_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
88 crap_target_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
89 entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
90 value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
91 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
92 cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>>,
93 direct_callers: rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>>,
94 istanbul_matched: usize,
95 istanbul_total: usize,
96 istanbul_files_joined: usize,
97 istanbul_files_total: usize,
98 coverage_input_format: Option<fallow_output::CoverageInputFormat>,
99 per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
100 template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
101}
102
103#[derive(Clone, Default)]
109pub struct AnalysisCountsSnapshot {
110 unused_file_paths: Vec<std::path::PathBuf>,
112 unused_export_paths: Vec<std::path::PathBuf>,
115 unused_dep_package_paths: Vec<std::path::PathBuf>,
119 circular_dep_groups: Vec<Vec<std::path::PathBuf>>,
122 module_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
125}
126
127impl AnalysisCountsSnapshot {
128 pub(crate) fn counts_for(
144 &self,
145 subset: &crate::health::SubsetFilter<'_>,
146 defaults: &crate::vital_signs::AnalysisCounts,
147 ) -> crate::vital_signs::AnalysisCounts {
148 if subset.is_full() {
149 return *defaults;
150 }
151 let dead_files = self
152 .unused_file_paths
153 .iter()
154 .filter(|p| subset.matches(p))
155 .count();
156 let dead_exports = self
157 .unused_export_paths
158 .iter()
159 .filter(|p| subset.matches(p))
160 .count();
161 let unused_deps = self
162 .unused_dep_package_paths
163 .iter()
164 .filter(|dep_path| dep_in_subset(subset, dep_path))
165 .count();
166 let circular_deps = self
167 .circular_dep_groups
168 .iter()
169 .filter(|cycle| cycle.iter().any(|p| subset.matches(p)))
170 .count();
171 let total_exports = self
172 .module_export_counts
173 .iter()
174 .filter(|(p, _)| subset.matches(p))
175 .map(|(_, n)| *n)
176 .sum();
177 crate::vital_signs::AnalysisCounts {
178 total_exports,
179 dead_files,
180 dead_exports,
181 unused_deps,
182 circular_deps,
183 total_deps: defaults.total_deps,
184 }
185 }
186}
187
188fn dep_in_subset(subset: &crate::health::SubsetFilter<'_>, dep_path: &std::path::Path) -> bool {
195 match subset {
196 crate::health::SubsetFilter::Full => true,
197 crate::health::SubsetFilter::Paths(set) => {
198 let Some(workspace_root) = dep_path.parent() else {
199 return false;
200 };
201 set.iter().any(|p| p.starts_with(workspace_root))
202 }
203 }
204}
205
206#[expect(
216 clippy::cast_possible_truncation,
217 reason = "line count is bounded by source file size"
218)]
219fn aggregate_complexity(module: &crate::source::ModuleInfo) -> (u32, u32, usize, u32) {
220 let cyc: u32 = module
221 .complexity
222 .iter()
223 .map(|f| u32::from(f.cyclomatic))
224 .sum();
225 let cog: u32 = module
226 .complexity
227 .iter()
228 .map(|f| u32::from(f.cognitive))
229 .sum();
230 let funcs = module.complexity.len();
231 let lines = module.line_offsets.len() as u32;
232 (cyc, cog, funcs, lines)
233}
234
235fn compute_dead_code_ratio(
243 path: &std::path::Path,
244 exports: &[fallow_graph::graph::ExportSymbol],
245 unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
246 unused_exports_by_path: &rustc_hash::FxHashMap<&std::path::Path, usize>,
247) -> f64 {
248 if unused_files.contains(path) {
249 return 1.0;
250 }
251 let value_exports = exports.iter().filter(|e| !e.is_type_only).count();
252 if value_exports == 0 {
253 return 0.0;
254 }
255 let unused = unused_exports_by_path.get(path).copied().unwrap_or(0);
256 (unused as f64 / value_exports as f64).min(1.0)
257}
258
259fn compute_complexity_density(total_cyclomatic: u32, lines: u32) -> f64 {
263 if lines > 0 {
264 f64::from(total_cyclomatic) / f64::from(lines)
265 } else {
266 0.0
267 }
268}
269
270pub(super) const CRAP_THRESHOLD: f64 = 30.0;
273
274#[derive(Clone, Copy)]
277pub(super) struct CrapScoreThresholds<'a> {
278 pub(super) resolver: &'a ThresholdOverrideResolver,
279 pub(super) enforce_crap: bool,
280}
281
282pub(super) struct CrapCeilingLookup<'a> {
289 resolver: &'a ThresholdOverrideResolver,
290 relative: &'a std::path::Path,
291 enforce_crap: bool,
292 suppressions: &'a [crate::suppress::Suppression],
293}
294
295#[derive(Debug, Default)]
302struct CrapThresholdSignals {
303 above: usize,
305 exempted: usize,
309 min_ceiling: Option<f64>,
311 suppressed: usize,
312 unsuppressed_max: f64,
313 unsuppressed_min_ceiling: Option<f64>,
314 eligible_functions: Vec<(String, u32, u16)>,
316}
317
318impl<'a> CrapCeilingLookup<'a> {
319 pub(super) fn new(
320 thresholds: CrapScoreThresholds<'a>,
321 relative: &'a std::path::Path,
322 suppressions: &'a [crate::suppress::Suppression],
323 ) -> Self {
324 Self {
325 resolver: thresholds.resolver,
326 relative,
327 enforce_crap: thresholds.enforce_crap,
328 suppressions,
329 }
330 }
331
332 fn observe(
335 &self,
336 function: &fallow_types::extract::FunctionComplexity,
337 crap_rounded: f64,
338 signals: &mut CrapThresholdSignals,
339 ) {
340 let ceiling = self
341 .resolver
342 .effective_max_crap(self.relative, &function.name);
343 signals.min_ceiling = Some(signals.min_ceiling.map_or(ceiling, |m| m.min(ceiling)));
344 if (!self.enforce_crap || crap_rounded < ceiling) && crap_rounded >= CRAP_THRESHOLD {
345 signals.exempted += 1;
346 }
347 if crate::suppress::is_suppressed(
348 self.suppressions,
349 function.line,
350 crate::suppress::IssueKind::Complexity,
351 ) {
352 signals.suppressed += 1;
353 return;
354 }
355 signals.unsuppressed_max = signals.unsuppressed_max.max(crap_rounded);
356 signals.unsuppressed_min_ceiling = Some(
357 signals
358 .unsuppressed_min_ceiling
359 .map_or(ceiling, |minimum| minimum.min(ceiling)),
360 );
361 if self.enforce_crap && crap_rounded >= ceiling {
362 signals.above += 1;
363 signals.eligible_functions.push((
364 function.name.clone(),
365 function.line,
366 function.cognitive,
367 ));
368 }
369 }
370}
371
372#[derive(Debug, Clone, Copy)]
374pub struct PerFunctionCrap {
375 pub(crate) line: u32,
377 pub(crate) col: u32,
383 pub(crate) crap: f64,
385 pub(crate) coverage_pct: Option<f64>,
388 pub(crate) coverage_tier: fallow_output::CoverageTier,
392 pub(crate) coverage_source: fallow_output::CoverageSource,
399}
400
401#[derive(Debug)]
403struct IstanbulCrapResult {
404 pub max_crap: f64,
405 pub signals: CrapThresholdSignals,
407 pub matched: usize,
409 pub total: usize,
411 pub per_function: Vec<PerFunctionCrap>,
413}
414
415fn compute_crap_scores_istanbul(
426 complexity: &[fallow_types::extract::FunctionComplexity],
427 file_coverage: Option<&IstanbulFileCoverage>,
428 is_test_reachable: bool,
429 ceilings: &CrapCeilingLookup<'_>,
430) -> IstanbulCrapResult {
431 if complexity.is_empty() {
432 return IstanbulCrapResult {
433 max_crap: 0.0,
434 signals: CrapThresholdSignals::default(),
435 matched: 0,
436 total: 0,
437 per_function: Vec::new(),
438 };
439 }
440 let mut max = 0.0_f64;
441 let mut signals = CrapThresholdSignals::default();
442 let mut matched = 0usize;
443 let mut total = 0usize;
444 let mut per_function = Vec::with_capacity(complexity.len());
445 for f in complexity {
446 if fallow_types::extract::is_synthetic_template_unit(&f.name)
455 || fallow_types::extract::is_synthetic_module_unit(&f.name)
456 {
457 continue;
458 }
459 total += 1;
460 let cc = f64::from(ceilings.resolver.gate_cyclomatic(f));
461 let (crap, coverage_pct, tier, source) =
462 crap_for_function(f, cc, file_coverage, is_test_reachable, &mut matched);
463 let crap_rounded = (crap * 10.0).round() / 10.0;
464 max = max.max(crap);
465 ceilings.observe(f, crap_rounded, &mut signals);
466 per_function.push(PerFunctionCrap {
467 line: f.line,
468 col: f.col,
469 crap: crap_rounded,
470 coverage_pct,
471 coverage_tier: tier,
472 coverage_source: source,
473 });
474 }
475 IstanbulCrapResult {
476 max_crap: (max * 10.0).round() / 10.0,
477 signals,
478 matched,
479 total,
480 per_function,
481 }
482}
483
484#[expect(
488 clippy::suboptimal_flops,
489 reason = "cc * cc + cc matches the CRAP formula specification"
490)]
491fn crap_for_function(
492 f: &fallow_types::extract::FunctionComplexity,
493 cc: f64,
494 file_coverage: Option<&IstanbulFileCoverage>,
495 is_test_reachable: bool,
496 matched: &mut usize,
497) -> (
498 f64,
499 Option<f64>,
500 fallow_output::CoverageTier,
501 fallow_output::CoverageSource,
502) {
503 let lookup = file_coverage.and_then(|fc| fc.lookup_function(f));
504 if let Some(cov_pct) = lookup {
505 *matched += 1;
506 return (
507 crap_formula(cc, cov_pct),
508 Some(cov_pct),
509 fallow_output::CoverageTier::from_pct(cov_pct),
510 fallow_output::CoverageSource::Istanbul,
511 );
512 }
513 if is_test_reachable {
517 return (
518 crap_formula(cc, INDIRECT_TEST_COVERAGE_ESTIMATE),
519 None,
520 fallow_output::CoverageTier::from_pct(INDIRECT_TEST_COVERAGE_ESTIMATE),
521 fallow_output::CoverageSource::Estimated,
522 );
523 }
524 (
525 cc * cc + cc,
526 None,
527 fallow_output::CoverageTier::None,
528 fallow_output::CoverageSource::Estimated,
529 )
530}
531
532const DIRECT_TEST_COVERAGE_ESTIMATE: f64 = 85.0;
535
536const INDIRECT_TEST_COVERAGE_ESTIMATE: f64 = 40.0;
540const MAX_DIRECT_CALLER_EVIDENCE: usize = 5;
541
542#[derive(Debug)]
552struct EstimatedCrapResult {
553 pub max_crap: f64,
554 pub signals: CrapThresholdSignals,
556 pub per_function: Vec<PerFunctionCrap>,
557}
558
559fn compute_crap_scores_estimated(
560 complexity: &[fallow_types::extract::FunctionComplexity],
561 test_referenced_exports: &rustc_hash::FxHashSet<String>,
562 is_test_reachable: bool,
563 coverage_source: fallow_output::CoverageSource,
564 ceilings: &CrapCeilingLookup<'_>,
565) -> EstimatedCrapResult {
566 if complexity.is_empty() {
567 return EstimatedCrapResult {
568 max_crap: 0.0,
569 signals: CrapThresholdSignals::default(),
570 per_function: Vec::new(),
571 };
572 }
573 let mut max = 0.0_f64;
574 let mut signals = CrapThresholdSignals::default();
575 let mut per_function = Vec::with_capacity(complexity.len());
576 for f in complexity {
577 if fallow_types::extract::is_synthetic_template_unit(&f.name)
582 || fallow_types::extract::is_synthetic_module_unit(&f.name)
583 {
584 continue;
585 }
586 let cc = f64::from(ceilings.resolver.gate_cyclomatic(f));
587 let estimated_coverage = if test_referenced_exports.contains(f.identity_name()) {
591 DIRECT_TEST_COVERAGE_ESTIMATE
592 } else if is_test_reachable {
593 INDIRECT_TEST_COVERAGE_ESTIMATE
594 } else {
595 0.0
596 };
597 let crap = crap_formula(cc, estimated_coverage);
598 let crap_rounded = (crap * 10.0).round() / 10.0;
599 max = max.max(crap);
600 ceilings.observe(f, crap_rounded, &mut signals);
601 per_function.push(PerFunctionCrap {
602 line: f.line,
603 col: f.col,
604 crap: crap_rounded,
605 coverage_pct: None,
606 coverage_tier: fallow_output::CoverageTier::from_pct(estimated_coverage),
607 coverage_source,
608 });
609 }
610 EstimatedCrapResult {
611 max_crap: (max * 10.0).round() / 10.0,
612 signals,
613 per_function,
614 }
615}
616
617#[derive(Debug, Clone)]
631pub(super) struct TemplateInheritContext {
632 pub is_test_reachable: bool,
633 pub test_referenced_exports: rustc_hash::FxHashSet<String>,
634 pub provenance_owner: std::path::PathBuf,
639}
640
641fn build_template_inherit_contexts(
663 graph: &fallow_graph::graph::ModuleGraph,
664 test_coverage: StaticTestCoverage<'_>,
665 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
666 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
667) -> rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext> {
668 let mut out = rustc_hash::FxHashMap::default();
669 for node in &graph.modules {
670 if let Some(context) =
671 template_inherit_context_for_node(node, graph, test_coverage, module_by_id, file_paths)
672 {
673 out.insert(node.file_id, context);
674 }
675 }
676 out
677}
678
679fn template_inherit_context_for_node(
680 node: &fallow_graph::graph::ModuleNode,
681 graph: &fallow_graph::graph::ModuleGraph,
682 test_coverage: StaticTestCoverage<'_>,
683 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
684 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
685) -> Option<TemplateInheritContext> {
686 if !is_template_inherit_candidate(node, module_by_id, file_paths) {
687 return None;
688 }
689 let importers = graph.reverse_deps.get(node.file_id.0 as usize)?;
690 template_inherit_context_from_importers(
691 importers,
692 graph,
693 test_coverage,
694 module_by_id,
695 file_paths,
696 )
697}
698
699fn is_template_inherit_candidate(
700 node: &fallow_graph::graph::ModuleNode,
701 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
702 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
703) -> bool {
704 let Some(path) = file_paths.get(&node.file_id) else {
705 return false;
706 };
707 if !path
708 .extension()
709 .and_then(|ext| ext.to_str())
710 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
711 {
712 return false;
713 }
714 module_by_id.get(&node.file_id).is_some_and(|module| {
715 module
716 .complexity
717 .iter()
718 .any(|finding| finding.name.as_str() == "<template>")
719 })
720}
721
722fn template_inherit_context_from_importers(
723 importers: &[crate::discover::FileId],
724 graph: &fallow_graph::graph::ModuleGraph,
725 test_coverage: StaticTestCoverage<'_>,
726 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
727 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
728) -> Option<TemplateInheritContext> {
729 let mut any_reachable = false;
730 let mut combined_refs = rustc_hash::FxHashSet::default();
731 let mut provenance: Option<std::path::PathBuf> = None;
732 let mut first_owner: Option<std::path::PathBuf> = None;
733
734 for &importer_id in importers {
735 let Some((owner_node, owner_path)) =
736 template_owner(importer_id, graph, module_by_id, file_paths)
737 else {
738 continue;
739 };
740 if first_owner.is_none() {
741 first_owner = Some((*owner_path).clone());
742 }
743 if test_coverage.covers_file(owner_node.file_id) {
744 any_reachable = true;
745 provenance.get_or_insert_with(|| (*owner_path).clone());
746 let refs = build_test_referenced_exports(
747 owner_node.file_id,
748 &owner_node.exports,
749 test_coverage,
750 );
751 combined_refs.extend(refs);
752 }
753 }
754
755 let provenance_owner = provenance.or(first_owner)?;
756 Some(TemplateInheritContext {
757 is_test_reachable: any_reachable,
758 test_referenced_exports: combined_refs,
759 provenance_owner,
760 })
761}
762
763fn template_owner<'a>(
764 importer_id: crate::discover::FileId,
765 graph: &'a fallow_graph::graph::ModuleGraph,
766 module_by_id: &rustc_hash::FxHashMap<crate::discover::FileId, &crate::source::ModuleInfo>,
767 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
768) -> Option<(&'a fallow_graph::graph::ModuleNode, &'a std::path::PathBuf)> {
769 let owner_node = graph.modules.get(importer_id.0 as usize)?;
770 let owner_path = *file_paths.get(&importer_id)?;
771 if !is_template_owner_path(owner_path) || graph.test_entry_points.contains(&importer_id) {
772 return None;
773 }
774 let owner_has_component = module_by_id
775 .get(&importer_id)
776 .is_some_and(|module| module.has_angular_component_template_url);
777 owner_has_component.then_some((owner_node, owner_path))
778}
779
780fn is_template_owner_path(path: &std::path::Path) -> bool {
781 path.extension()
782 .and_then(|ext| ext.to_str())
783 .is_some_and(|ext| {
784 matches!(
785 ext.to_ascii_lowercase().as_str(),
786 "ts" | "tsx" | "mts" | "cts"
787 )
788 })
789}
790
791fn collect_direct_callers(
792 graph: &fallow_graph::graph::ModuleGraph,
793 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
794) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<DirectCallerEvidence>> {
795 let mut callers_by_target = rustc_hash::FxHashMap::default();
796 for node in &graph.modules {
797 let Some(target_path) = file_paths.get(&node.file_id) else {
798 continue;
799 };
800 let mut callers = graph
801 .direct_importer_summaries(node.file_id)
802 .into_iter()
803 .filter_map(|summary| {
804 file_paths
805 .get(&summary.source)
806 .map(|caller_path| DirectCallerEvidence {
807 path: (*caller_path).clone(),
808 symbols: summary
809 .symbols
810 .into_iter()
811 .map(|symbol| DirectCallerSymbolEvidence {
812 imported: symbol.imported,
813 local: symbol.local,
814 type_only: symbol.type_only,
815 })
816 .collect(),
817 })
818 })
819 .collect::<Vec<_>>();
820 callers.sort_by(|a, b| a.path.cmp(&b.path));
821 callers.truncate(MAX_DIRECT_CALLER_EVIDENCE);
822 if !callers.is_empty() {
823 callers_by_target.insert((*target_path).clone(), callers);
824 }
825 }
826 callers_by_target
827}
828
829#[expect(
832 clippy::suboptimal_flops,
833 reason = "explicit multiplication matches the CRAP formula specification"
834)]
835fn crap_formula(cc: f64, coverage_pct: f64) -> f64 {
836 let uncovered = 1.0 - coverage_pct / 100.0;
837 cc * cc * uncovered * uncovered * uncovered + cc
838}
839
840const ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT: u32 = 16;
846
847const ALIAS_FUZZ_MAX_LINE_DRIFT: u32 = 2;
852
853#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
854struct IstanbulPosition {
855 line: u32,
856 col: u32,
857}
858
859impl IstanbulPosition {
860 const fn new(line: u32, col: u32) -> Self {
861 Self { line, col }
862 }
863
864 const fn distance_from(self, target: Self) -> (u32, u32) {
865 (
866 self.line.abs_diff(target.line),
867 self.col.abs_diff(target.col),
868 )
869 }
870}
871
872#[derive(Clone, Copy, Debug, Eq, PartialEq)]
873struct IstanbulSpan {
874 start: IstanbulPosition,
875 end: IstanbulPosition,
876}
877
878impl IstanbulSpan {
879 fn from_entry(
880 fn_entry: &oxc_coverage_instrument::FnEntry,
881 source_index: Option<&IstanbulSourceIndex<'_>>,
882 ) -> Option<Self> {
883 let start = normalized_istanbul_position(
884 fn_entry.loc.start.line,
885 fn_entry.loc.start.column,
886 source_index,
887 )?;
888 let end = normalized_istanbul_position(
889 fn_entry.loc.end.line,
890 fn_entry.loc.end.column,
891 source_index,
892 )?;
893 (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
894 }
895
896 fn header_from_entry(
897 fn_entry: &oxc_coverage_instrument::FnEntry,
898 source_index: Option<&IstanbulSourceIndex<'_>>,
899 ) -> Option<Self> {
900 let start = normalized_istanbul_position(
901 fn_entry.decl.start.line,
902 fn_entry.decl.start.column,
903 source_index,
904 )?;
905 let end = normalized_istanbul_position(
906 fn_entry.loc.start.line,
907 fn_entry.loc.start.column,
908 source_index,
909 )?;
910 (start.line > 0 && end.line > 0 && start < end).then_some(Self { start, end })
911 }
912
913 fn contains(self, position: IstanbulPosition) -> bool {
915 self.start <= position && position < self.end
916 }
917
918 fn strictly_contains(self, other: Self) -> bool {
919 self != other && self.start <= other.start && other.end <= self.end
920 }
921}
922
923#[derive(Clone, Copy, Debug, Eq, PartialEq)]
932struct IstanbulAlias {
933 position: IstanbulPosition,
934 primary: bool,
935}
936
937#[derive(Clone, Copy, Debug, Default)]
940struct IstanbulAliasCounts {
941 primary: usize,
942 secondary: usize,
943}
944
945impl IstanbulAliasCounts {
946 fn add(&mut self, alias: IstanbulAlias) {
947 if alias.primary {
948 self.primary += 1;
949 } else {
950 self.secondary += 1;
951 }
952 }
953
954 const fn is_unique(self, alias: IstanbulAlias) -> bool {
957 if alias.primary {
958 self.primary == 1
959 } else {
960 self.primary == 0 && self.secondary == 1
961 }
962 }
963
964 const fn has_secondary_only_collision(self) -> bool {
965 self.primary == 0 && self.secondary > 1
966 }
967}
968
969fn is_anonymous_istanbul_name(name: &str) -> bool {
970 name.starts_with("(anonymous_")
971}
972
973struct IstanbulFunctionCoverage {
974 name: String,
975 coverage_pct: f64,
976 aliases: Vec<IstanbulAlias>,
977 decl_start: IstanbulPosition,
981 header_holds_other_fn: bool,
985 header_span: Option<IstanbulSpan>,
986 body_span: Option<IstanbulSpan>,
987}
988
989impl IstanbulFunctionCoverage {
990 fn answers_to(&self, name: &str) -> bool {
993 if self.name == name {
994 return true;
995 }
996 accessor_property_name(&self.name) == Some(name)
997 }
998
999 fn nearest_alias(
1000 &self,
1001 target: IstanbulPosition,
1002 max_column_drift: Option<u32>,
1003 ) -> Option<(u32, u32)> {
1004 self.aliases
1005 .iter()
1006 .filter_map(|alias| {
1007 let distance = alias.position.distance_from(target);
1008 if distance.0 > ALIAS_FUZZ_MAX_LINE_DRIFT {
1009 return None;
1010 }
1011 if distance.0 > 0 && max_column_drift.is_some_and(|maximum| distance.1 > maximum) {
1012 return None;
1013 }
1014 Some(distance)
1015 })
1016 .min()
1017 }
1018}
1019
1020pub struct IstanbulFileCoverage {
1023 functions: Vec<IstanbulFunctionCoverage>,
1027 alias_index: rustc_hash::FxHashMap<(String, u32, u32), usize>,
1032 ambiguous_aliases: rustc_hash::FxHashSet<(String, u32, u32)>,
1036 ambiguous_anonymous_aliases: rustc_hash::FxHashSet<IstanbulPosition>,
1040 alias_lines: Vec<(u32, usize)>,
1045 header_starts: Vec<(u32, usize)>,
1048 max_header_height: u32,
1052 relocated: bool,
1056}
1057
1058fn mark_headers_holding_other_fns(functions: &mut [IstanbulFunctionCoverage]) {
1064 let mut declarations: Vec<IstanbulPosition> = functions
1065 .iter()
1066 .map(|function| function.decl_start)
1067 .collect();
1068 declarations.sort_unstable();
1069 for function in functions {
1070 let Some(span) = function.header_span else {
1071 continue;
1072 };
1073 let first = declarations.partition_point(|position| *position < span.start);
1076 let past = declarations.partition_point(|position| *position < span.end);
1077 function.header_holds_other_fn = past - first > 1;
1078 }
1079}
1080
1081struct IstanbulLineIndexes {
1085 alias_lines: Vec<(u32, usize)>,
1086 header_starts: Vec<(u32, usize)>,
1087 max_header_height: u32,
1088}
1089
1090impl IstanbulLineIndexes {
1091 fn build(functions: &[IstanbulFunctionCoverage]) -> Self {
1092 let mut alias_lines: Vec<(u32, usize)> = functions
1093 .iter()
1094 .enumerate()
1095 .flat_map(|(function_index, function)| {
1096 function
1097 .aliases
1098 .iter()
1099 .map(move |alias| (alias.position.line, function_index))
1100 })
1101 .collect();
1102 alias_lines.sort_unstable();
1103
1104 let mut header_starts: Vec<(u32, usize)> = Vec::new();
1105 let mut max_header_height = 0;
1106 for (function_index, function) in functions.iter().enumerate() {
1107 if let Some(span) = function.header_span {
1108 header_starts.push((span.start.line, function_index));
1109 max_header_height = max_header_height.max(span.end.line - span.start.line);
1112 }
1113 }
1114 header_starts.sort_unstable();
1115
1116 Self {
1117 alias_lines,
1118 header_starts,
1119 max_header_height,
1120 }
1121 }
1122}
1123
1124impl IstanbulFileCoverage {
1125 pub fn lookup_function(
1131 &self,
1132 function: &fallow_types::extract::FunctionComplexity,
1133 ) -> Option<f64> {
1134 if function.is_private_member {
1135 return self.lookup_private(&function.name, function.line, function.col);
1136 }
1137 self.lookup(function.identity_name(), function.line, function.col)
1142 }
1143
1144 fn new(mut functions: Vec<IstanbulFunctionCoverage>, relocated: bool) -> Self {
1145 let mut named_alias_counts: rustc_hash::FxHashMap<
1146 (String, IstanbulPosition),
1147 IstanbulAliasCounts,
1148 > = rustc_hash::FxHashMap::default();
1149 let mut anonymous_alias_counts: rustc_hash::FxHashMap<
1150 IstanbulPosition,
1151 IstanbulAliasCounts,
1152 > = rustc_hash::FxHashMap::default();
1153 for function in &functions {
1154 let is_anonymous = is_anonymous_istanbul_name(&function.name);
1155 for alias in &function.aliases {
1156 named_alias_counts
1157 .entry((function.name.clone(), alias.position))
1158 .or_default()
1159 .add(*alias);
1160 if is_anonymous {
1161 anonymous_alias_counts
1162 .entry(alias.position)
1163 .or_default()
1164 .add(*alias);
1165 }
1166 }
1167 }
1168
1169 let mut ambiguous_aliases = rustc_hash::FxHashSet::default();
1174 let mut ambiguous_anonymous_aliases = rustc_hash::FxHashSet::default();
1175 for function in &mut functions {
1176 let name = function.name.clone();
1177 let is_anonymous = is_anonymous_istanbul_name(&name);
1178 function.aliases.retain(|alias| {
1179 let named = named_alias_counts
1180 .get(&(name.clone(), alias.position))
1181 .copied()
1182 .unwrap_or_default();
1183 let anonymous = is_anonymous
1184 .then(|| anonymous_alias_counts.get(&alias.position).copied())
1185 .flatten();
1186 let unique = named.is_unique(*alias)
1187 && anonymous.is_none_or(|counts| counts.is_unique(*alias));
1188 if unique {
1189 return true;
1190 }
1191 if alias.primary {
1192 ambiguous_aliases.insert((
1193 name.clone(),
1194 alias.position.line,
1195 alias.position.col,
1196 ));
1197 if anonymous.is_some_and(|counts| counts.primary > 1) {
1198 ambiguous_anonymous_aliases.insert(alias.position);
1199 }
1200 } else {
1201 if named.has_secondary_only_collision() {
1202 ambiguous_aliases.insert((
1203 name.clone(),
1204 alias.position.line,
1205 alias.position.col,
1206 ));
1207 }
1208 if anonymous.is_some_and(IstanbulAliasCounts::has_secondary_only_collision) {
1209 ambiguous_anonymous_aliases.insert(alias.position);
1210 }
1211 }
1212 false
1213 });
1214 }
1215
1216 let mut alias_index = rustc_hash::FxHashMap::default();
1217 for (function_index, function) in functions.iter().enumerate() {
1218 for alias in &function.aliases {
1219 alias_index.insert(
1220 (
1221 function.name.clone(),
1222 alias.position.line,
1223 alias.position.col,
1224 ),
1225 function_index,
1226 );
1227 if let Some(property) = accessor_property_name(&function.name) {
1230 alias_index
1231 .entry((
1232 property.to_string(),
1233 alias.position.line,
1234 alias.position.col,
1235 ))
1236 .or_insert(function_index);
1237 }
1238 }
1239 }
1240
1241 mark_headers_holding_other_fns(&mut functions);
1242 let indexes = IstanbulLineIndexes::build(&functions);
1243
1244 Self {
1245 functions,
1246 alias_index,
1247 ambiguous_aliases,
1248 ambiguous_anonymous_aliases,
1249 alias_lines: indexes.alias_lines,
1250 header_starts: indexes.header_starts,
1251 max_header_height: indexes.max_header_height,
1252 relocated,
1253 }
1254 }
1255
1256 fn fuzz_window(&self, target: IstanbulPosition) -> Vec<usize> {
1268 let low = target.line.saturating_sub(ALIAS_FUZZ_MAX_LINE_DRIFT);
1269 let high = target.line.saturating_add(ALIAS_FUZZ_MAX_LINE_DRIFT);
1270 let first = self.alias_lines.partition_point(|(line, _)| *line < low);
1271 let past = self.alias_lines.partition_point(|(line, _)| *line <= high);
1272 let mut window: Vec<usize> = self.alias_lines[first..past]
1273 .iter()
1274 .map(|(_, function_index)| *function_index)
1275 .collect();
1276 window.sort_unstable();
1277 window.dedup();
1278 window
1279 }
1280
1281 fn lookup_private(&self, name: &str, line: u32, col: u32) -> Option<f64> {
1290 let exact_key = (name.to_string(), line, col);
1291 if self.ambiguous_aliases.contains(&exact_key) {
1292 return None;
1293 }
1294 if let Some(&function_index) = self.alias_index.get(&exact_key) {
1295 return Some(self.functions[function_index].coverage_pct);
1296 }
1297 let target = IstanbulPosition::new(line, col);
1298 let window = self.fuzz_window(target);
1299 let signature_owners = self.header_spans_containing(target);
1300 let mut candidates = self.named_candidates(name, target, &window, &signature_owners);
1301 let (_, function_index) = candidates.next()?;
1302 if candidates.next().is_some() {
1303 return None;
1304 }
1305 Some(self.functions[function_index].coverage_pct)
1306 }
1307
1308 fn named_candidates<'a>(
1317 &'a self,
1318 name: &'a str,
1319 target: IstanbulPosition,
1320 window: &'a [usize],
1321 signature_owners: &'a [usize],
1322 ) -> impl Iterator<Item = ((u32, u32), usize)> + 'a {
1323 window
1324 .iter()
1325 .copied()
1326 .filter(move |function_index| self.functions[*function_index].answers_to(name))
1327 .filter_map(move |function_index| {
1328 self.functions[function_index]
1329 .nearest_alias(target, None)
1330 .map(|distance| (distance, function_index))
1331 })
1332 .filter(move |(distance, function_index)| {
1333 *distance == (0, 0)
1334 || !self.foreign_signature_blocks(signature_owners, *function_index, target)
1335 })
1336 }
1337
1338 pub(crate) fn lookup(&self, name: &str, line: u32, col: u32) -> Option<f64> {
1371 let exact_key = (name.to_string(), line, col);
1372 if self.ambiguous_aliases.contains(&exact_key) {
1373 return None;
1374 }
1375 if let Some(&function_index) = self.alias_index.get(&exact_key) {
1376 return Some(self.functions[function_index].coverage_pct);
1377 }
1378
1379 let target = IstanbulPosition::new(line, col);
1380 let window = self.fuzz_window(target);
1381 let signature_owners = self.header_spans_containing(target);
1382 if let Some(function) = self
1383 .named_candidates(name, target, &window, &signature_owners)
1384 .min_by_key(|(distance, _)| *distance)
1385 .map(|(_, function_index)| &self.functions[function_index])
1386 {
1387 return Some(function.coverage_pct);
1388 }
1389 if self.relocated
1390 && let Some(pct) = self.unambiguous_named_pct(name)
1391 {
1392 return Some(pct);
1393 }
1394 if self.ambiguous_anonymous_aliases.contains(&target) {
1395 return self.unique_anonymous_header_match(&signature_owners);
1396 }
1397
1398 let mut nearest_distance: Option<(u32, u32)> = None;
1399 let mut nearest_functions = Vec::new();
1400 for function_index in window {
1401 let function = &self.functions[function_index];
1402 if !is_anonymous_istanbul_name(&function.name) {
1403 continue;
1404 }
1405 let Some(distance) =
1406 function.nearest_alias(target, Some(ANONYMOUS_FALLBACK_MAX_COLUMN_DRIFT))
1407 else {
1408 continue;
1409 };
1410 if distance != (0, 0)
1414 && self.foreign_signature_blocks(&signature_owners, function_index, target)
1415 {
1416 continue;
1417 }
1418 match nearest_distance {
1419 None => {
1420 nearest_distance = Some(distance);
1421 nearest_functions.push(function_index);
1422 }
1423 Some(previous) if distance < previous => {
1424 nearest_distance = Some(distance);
1425 nearest_functions.clear();
1426 nearest_functions.push(function_index);
1427 }
1428 Some(previous) if distance == previous => {
1429 nearest_functions.push(function_index);
1430 }
1431 Some(_) => {}
1432 }
1433 }
1434 let established_match = match nearest_functions.as_slice() {
1435 [] => None,
1436 [function_index] => Some(self.functions[*function_index].coverage_pct),
1437 tied => self.innermost_anonymous_match(tied, target),
1438 };
1439 if established_match.is_some() {
1440 return established_match;
1441 }
1442
1443 self.unique_anonymous_header_match(&signature_owners)
1444 }
1445
1446 fn header_spans_containing(&self, target: IstanbulPosition) -> Vec<usize> {
1451 let low = target.line.saturating_sub(self.max_header_height);
1452 let first = self.header_starts.partition_point(|(line, _)| *line < low);
1453 let past = self
1454 .header_starts
1455 .partition_point(|(line, _)| *line <= target.line);
1456 self.header_starts[first..past]
1457 .iter()
1458 .filter(|(_, function_index)| {
1459 self.functions[*function_index]
1460 .header_span
1461 .is_some_and(|span| span.contains(target))
1462 })
1463 .map(|(_, function_index)| *function_index)
1464 .collect()
1465 }
1466
1467 fn foreign_signature_blocks(
1478 &self,
1479 owners: &[usize],
1480 candidate: usize,
1481 target: IstanbulPosition,
1482 ) -> bool {
1483 let function = &self.functions[candidate];
1484 if function
1485 .header_span
1486 .is_some_and(|span| span.contains(target))
1487 || function.body_span.is_some_and(|span| span.contains(target))
1488 {
1489 return false;
1490 }
1491 owners.iter().any(|&owner| {
1492 owner != candidate
1493 && self.functions[owner]
1494 .header_span
1495 .is_some_and(|span| span.contains(function.decl_start))
1496 })
1497 }
1498
1499 fn unique_anonymous_header_match(&self, signature_owners: &[usize]) -> Option<f64> {
1511 let [function_index] = signature_owners else {
1512 return None;
1513 };
1514 let function = &self.functions[*function_index];
1515 if !is_anonymous_istanbul_name(&function.name) || function.header_holds_other_fn {
1516 return None;
1517 }
1518 Some(function.coverage_pct)
1519 }
1520
1521 fn innermost_anonymous_match(&self, tied: &[usize], target: IstanbulPosition) -> Option<f64> {
1522 let containing: Option<Vec<_>> = tied
1523 .iter()
1524 .map(|&function_index| {
1525 self.functions[function_index]
1526 .body_span
1527 .filter(|span| span.contains(target))
1528 .map(|span| (function_index, span))
1529 })
1530 .collect();
1531 let containing = containing?;
1532
1533 let mut winner = None;
1534 for &(function_index, candidate_span) in &containing {
1535 let is_strictly_innermost = containing.iter().all(|&(other_index, other_span)| {
1536 other_index == function_index || other_span.strictly_contains(candidate_span)
1537 });
1538 if !is_strictly_innermost {
1539 continue;
1540 }
1541 if winner.replace(function_index).is_some() {
1542 return None;
1543 }
1544 }
1545 winner.map(|function_index| self.functions[function_index].coverage_pct)
1546 }
1547
1548 fn unambiguous_named_pct(&self, name: &str) -> Option<f64> {
1553 let mut found: Option<f64> = None;
1554 for function in &self.functions {
1555 if !function.answers_to(name) {
1556 continue;
1557 }
1558 match found {
1559 None => found = Some(function.coverage_pct),
1560 Some(previous) if previous.to_bits() == function.coverage_pct.to_bits() => {}
1561 Some(_) => return None,
1562 }
1563 }
1564 found
1565 }
1566}
1567
1568pub struct IstanbulCoverage {
1570 files: rustc_hash::FxHashMap<std::path::PathBuf, IstanbulFileCoverage>,
1571 format: fallow_output::CoverageInputFormat,
1572}
1573
1574impl IstanbulCoverage {
1575 pub fn get(&self, path: &std::path::Path) -> Option<&IstanbulFileCoverage> {
1577 self.files.get(path)
1578 }
1579
1580 pub fn file_count(&self) -> usize {
1582 self.files.len()
1583 }
1584
1585 pub const fn format(&self) -> fallow_output::CoverageInputFormat {
1587 self.format
1588 }
1589}
1590
1591enum CrapCoverageResolution<'a> {
1599 TemplateInherited(&'a TemplateInheritContext),
1600 Istanbul {
1601 file_coverage: Option<&'a IstanbulFileCoverage>,
1602 },
1603 StaticEstimated,
1604}
1605
1606fn resolve_crap_coverage<'a>(
1607 template_inherit: Option<&'a TemplateInheritContext>,
1608 istanbul_coverage: Option<&'a IstanbulCoverage>,
1609 path: &std::path::Path,
1610) -> CrapCoverageResolution<'a> {
1611 if let Some(inherit_ctx) = template_inherit {
1612 CrapCoverageResolution::TemplateInherited(inherit_ctx)
1613 } else if let Some(istanbul) = istanbul_coverage {
1614 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1615 CrapCoverageResolution::Istanbul {
1616 file_coverage: istanbul.get(&canonical),
1617 }
1618 } else {
1619 CrapCoverageResolution::StaticEstimated
1620 }
1621}
1622
1623pub fn auto_detect_coverage(root: &std::path::Path) -> Option<std::path::PathBuf> {
1631 let candidates = [
1632 root.join("coverage/coverage-final.json"),
1633 root.join(".nyc_output/coverage-final.json"),
1634 ];
1635 candidates.into_iter().find(|p| p.is_file())
1636}
1637
1638pub fn resolve_relative_to_root(
1644 path: &std::path::Path,
1645 project_root: Option<&std::path::Path>,
1646) -> std::path::PathBuf {
1647 if fallow_types::path_util::is_absolute_path_any_platform(path) {
1648 return path.to_path_buf();
1649 }
1650 match project_root {
1651 Some(root) => root.join(path),
1652 None => path.to_path_buf(),
1653 }
1654}
1655
1656#[cfg(test)]
1671fn load_istanbul_coverage(
1672 path: &std::path::Path,
1673 coverage_root: Option<&std::path::Path>,
1674 project_root: Option<&std::path::Path>,
1675 relocated: bool,
1676) -> Result<IstanbulCoverage, String> {
1677 load_istanbul_coverage_for_sources(path, coverage_root, project_root, None, relocated)
1678}
1679
1680pub(super) fn load_istanbul_coverage_for_sources(
1681 path: &std::path::Path,
1682 coverage_root: Option<&std::path::Path>,
1683 project_root: Option<&std::path::Path>,
1684 discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1685 relocated: bool,
1686) -> Result<IstanbulCoverage, String> {
1687 super::validate_coverage_root_absolute(coverage_root)?;
1688 let resolved = resolve_relative_to_root(path, project_root);
1689 match read_coverage_input(&resolved)? {
1690 CoverageInput::Istanbul { file_path, json } => {
1691 let raw = parse_coverage_map_tolerantly(&json).map_err(|e| {
1692 format!(
1693 "failed to parse coverage data from {}: {e}",
1694 file_path.display()
1695 )
1696 })?;
1697 let sources = CoverageMapSources {
1698 coverage_root,
1699 project_root,
1700 discovered_sources,
1701 relocated,
1702 };
1703 Ok(build_istanbul_coverage(
1704 &raw,
1705 &sources,
1706 fallow_output::CoverageInputFormat::Istanbul,
1707 ))
1708 }
1709 CoverageInput::V8 { dump_files } => {
1710 let scope = super::coverage_v8::V8ScriptScope {
1711 coverage_root,
1712 project_root,
1713 discovered_sources,
1714 };
1715 let raw = super::coverage_v8::load_v8_coverage_map(&dump_files, &scope)?;
1716 let sources = CoverageMapSources {
1718 coverage_root: None,
1719 project_root,
1720 discovered_sources,
1721 relocated,
1722 };
1723 Ok(build_istanbul_coverage(
1724 &raw,
1725 &sources,
1726 fallow_output::CoverageInputFormat::V8,
1727 ))
1728 }
1729 }
1730}
1731
1732enum CoverageInput {
1734 Istanbul {
1735 file_path: std::path::PathBuf,
1736 json: String,
1737 },
1738 V8 {
1739 dump_files: Vec<std::path::PathBuf>,
1740 },
1741}
1742
1743fn read_coverage_input(resolved: &std::path::Path) -> Result<CoverageInput, String> {
1747 if resolved.is_dir() {
1748 let candidate = resolved.join("coverage-final.json");
1749 if candidate.is_file() {
1750 return read_coverage_file(candidate);
1751 }
1752 let dump_files = super::coverage_v8::dump_files_in(resolved)?;
1753 if dump_files.is_empty() {
1754 return Err(format!(
1755 "no coverage-final.json or V8 coverage files found in {}",
1756 resolved.display()
1757 ));
1758 }
1759 return Ok(CoverageInput::V8 { dump_files });
1760 }
1761 read_coverage_file(resolved.to_path_buf())
1762}
1763
1764fn read_coverage_file(file_path: std::path::PathBuf) -> Result<CoverageInput, String> {
1765 let json = std::fs::read_to_string(&file_path)
1766 .map_err(|e| format!("failed to read coverage file {}: {e}", file_path.display()))?;
1767 if super::coverage_v8::is_v8_dump(&json) {
1768 return Ok(CoverageInput::V8 {
1769 dump_files: vec![file_path],
1770 });
1771 }
1772 Ok(CoverageInput::Istanbul { file_path, json })
1773}
1774
1775#[derive(Clone, Copy)]
1777struct CoverageMapSources<'a> {
1778 coverage_root: Option<&'a std::path::Path>,
1779 project_root: Option<&'a std::path::Path>,
1780 discovered_sources: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1781 relocated: bool,
1782}
1783
1784fn build_istanbul_coverage(
1785 raw: &std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>,
1786 sources: &CoverageMapSources<'_>,
1787 format: fallow_output::CoverageInputFormat,
1788) -> IstanbulCoverage {
1789 let CoverageMapSources {
1790 coverage_root,
1791 project_root,
1792 discovered_sources,
1793 relocated,
1794 } = *sources;
1795 let mut files = rustc_hash::FxHashMap::default();
1796 for file_cov in raw.values() {
1797 let raw_path = resolve_relative_to_root(std::path::Path::new(&file_cov.path), project_root);
1802 let file_path = if let (Some(cov_root), Some(proj_root)) = (coverage_root, project_root) {
1803 rebase_coverage_path(raw_path, cov_root, proj_root)
1804 } else {
1805 raw_path
1806 };
1807 let canonical = dunce::canonicalize(&file_path).unwrap_or(file_path);
1808 let source = read_discovered_source(&canonical, discovered_sources, relocated);
1809 let source_index = source
1810 .as_deref()
1811 .map(|source| IstanbulSourceIndex::new(source, &canonical));
1812
1813 let statement_owners = tally_statements_by_owner(file_cov);
1814 let mut functions = Vec::with_capacity(file_cov.fn_map.len());
1815 for (fn_id, fn_entry) in &file_cov.fn_map {
1816 let coverage_pct = compute_function_statement_coverage(
1817 file_cov,
1818 fn_id,
1819 statement_owners.get(fn_id.as_str()),
1820 );
1821 if let Some(function) =
1822 istanbul_function_coverage(fn_entry, coverage_pct, source_index.as_ref())
1823 {
1824 functions.push(function);
1825 }
1826 }
1827
1828 files.insert(canonical, IstanbulFileCoverage::new(functions, relocated));
1829 }
1830
1831 IstanbulCoverage { files, format }
1832}
1833
1834#[expect(
1835 clippy::filetype_is_file,
1836 reason = "coverage provenance must admit regular files and reject every special file type"
1837)]
1838fn read_discovered_source(
1839 path: &std::path::Path,
1840 discovered_sources: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
1841 relocated: bool,
1842) -> Option<String> {
1843 if relocated || !discovered_sources.is_some_and(|sources| sources.contains(path)) {
1844 return None;
1845 }
1846 if std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
1847 std::fs::read_to_string(path).ok()
1848 } else {
1849 None
1850 }
1851}
1852
1853fn parse_coverage_map_tolerantly(
1863 json: &str,
1864) -> Result<std::collections::BTreeMap<String, oxc_coverage_instrument::FileCoverage>, String> {
1865 match oxc_coverage_instrument::parse_coverage_map(json) {
1866 Ok(raw) => Ok(raw),
1867 Err(strict_error) => {
1868 let mut value: serde_json::Value =
1869 serde_json::from_str(json).map_err(|_| strict_error.to_string())?;
1870 let positions = clamp_negative_positions(&mut value);
1871 let counts = clamp_negative_counts(&mut value);
1872 if !positions && !counts {
1873 return Err(strict_error.to_string());
1874 }
1875 serde_json::from_value(value).map_err(|_| strict_error.to_string())
1876 }
1877 }
1878}
1879
1880fn clamp_negative_positions(value: &mut serde_json::Value) -> bool {
1884 match value {
1885 serde_json::Value::Object(entries) => {
1886 let mut clamped = false;
1887 for (key, child) in entries.iter_mut() {
1888 if matches!(key.as_str(), "line" | "column")
1889 && child.as_i64().is_some_and(|number| number < 0)
1890 {
1891 *child = serde_json::Value::from(0);
1892 clamped = true;
1893 continue;
1894 }
1895 clamped |= clamp_negative_positions(child);
1896 }
1897 clamped
1898 }
1899 serde_json::Value::Array(items) => items.iter_mut().fold(false, |clamped, item| {
1900 clamped | clamp_negative_positions(item)
1901 }),
1902 _ => false,
1903 }
1904}
1905
1906const COVERAGE_COUNT_KEYS: [&str; 4] = ["s", "f", "b", "b_T"];
1908
1909fn clamp_negative_counts(value: &mut serde_json::Value) -> bool {
1913 let serde_json::Value::Object(files) = value else {
1914 return false;
1915 };
1916 let mut clamped = false;
1917 for file in files.values_mut() {
1918 let serde_json::Value::Object(sections) = file else {
1919 continue;
1920 };
1921 for key in COVERAGE_COUNT_KEYS {
1922 if let Some(counts) = sections.get_mut(key) {
1923 clamped |= clamp_negative_numbers(counts);
1924 }
1925 }
1926 }
1927 clamped
1928}
1929
1930fn clamp_negative_numbers(value: &mut serde_json::Value) -> bool {
1933 match value {
1934 serde_json::Value::Number(number) if number.as_f64().is_some_and(|n| n < 0.0) => {
1935 *value = serde_json::Value::from(0);
1936 true
1937 }
1938 serde_json::Value::Object(entries) => entries.values_mut().fold(false, |clamped, child| {
1939 clamped | clamp_negative_numbers(child)
1940 }),
1941 serde_json::Value::Array(items) => items.iter_mut().fold(false, |clamped, item| {
1942 clamped | clamp_negative_numbers(item)
1943 }),
1944 _ => false,
1945 }
1946}
1947
1948fn accessor_property_name(name: &str) -> Option<&str> {
1956 let property = name
1957 .strip_prefix("get ")
1958 .or_else(|| name.strip_prefix("set "))?;
1959 (!property.is_empty() && !property.contains(' ')).then_some(property)
1960}
1961
1962fn rebase_coverage_path(
1970 raw_path: std::path::PathBuf,
1971 coverage_root: &std::path::Path,
1972 project_root: &std::path::Path,
1973) -> std::path::PathBuf {
1974 if let Ok(rel) = raw_path.strip_prefix(coverage_root) {
1975 return project_root.join(rel);
1976 }
1977 if let Ok(canonical) = dunce::canonicalize(&raw_path)
1978 && let Ok(rel) = canonical.strip_prefix(coverage_root)
1979 {
1980 return project_root.join(rel);
1981 }
1982 raw_path
1983}
1984
1985fn istanbul_function_coverage(
1986 fn_entry: &oxc_coverage_instrument::FnEntry,
1987 coverage_pct: f64,
1988 source_index: Option<&IstanbulSourceIndex<'_>>,
1989) -> Option<IstanbulFunctionCoverage> {
1990 let body_span = IstanbulSpan::from_entry(fn_entry, source_index);
1991 let header_span = IstanbulSpan::header_from_entry(fn_entry, source_index);
1992 let decl_start = normalized_istanbul_position(
1993 fn_entry.decl.start.line,
1994 fn_entry.decl.start.column,
1995 source_index,
1996 )?;
1997 let effective_position = normalized_istanbul_position(
1998 effective_istanbul_fn_line(fn_entry),
1999 fn_entry.decl.start.column,
2000 source_index,
2001 );
2002 let candidates = [
2003 effective_position.map(|position| IstanbulAlias {
2004 position,
2005 primary: true,
2006 }),
2007 Some(IstanbulAlias {
2008 position: decl_start,
2009 primary: true,
2010 }),
2011 body_span.map(|span| IstanbulAlias {
2012 position: span.start,
2013 primary: false,
2014 }),
2015 named_function_syntax_alias(fn_entry, source_index),
2016 ];
2017 let mut aliases: Vec<IstanbulAlias> = Vec::with_capacity(candidates.len());
2018 for candidate in candidates.into_iter().flatten() {
2019 if !aliases
2020 .iter()
2021 .any(|alias| alias.position == candidate.position)
2022 {
2023 aliases.push(candidate);
2024 }
2025 }
2026
2027 Some(IstanbulFunctionCoverage {
2028 name: fn_entry.name.clone(),
2029 coverage_pct,
2030 aliases,
2031 decl_start,
2032 header_holds_other_fn: false,
2033 header_span,
2034 body_span,
2035 })
2036}
2037
2038struct IstanbulSourceIndex<'a> {
2041 source: &'a str,
2042 line_starts: Vec<usize>,
2043 non_ascii_lines: rustc_hash::FxHashMap<usize, Utf16LineIndex>,
2044 named_function_starts: rustc_hash::FxHashMap<usize, usize>,
2045}
2046
2047struct Utf16LineIndex {
2048 utf16_len: u32,
2049 byte_len: usize,
2050 checkpoints: Vec<Utf16Checkpoint>,
2051}
2052
2053struct Utf16Checkpoint {
2054 utf16_start: u32,
2055 utf16_end: u32,
2056 byte_end: usize,
2057}
2058
2059impl<'a> IstanbulSourceIndex<'a> {
2060 fn new(source: &'a str, path: &std::path::Path) -> Self {
2061 let mut line_starts = vec![0];
2062 line_starts.extend(
2063 source
2064 .bytes()
2065 .enumerate()
2066 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
2067 );
2068 let non_ascii_lines = utf16_line_indexes(source, &line_starts);
2069
2070 let named_function_starts = named_function_starts_from_clean_parse(source, path);
2071
2072 Self {
2073 source,
2074 line_starts,
2075 non_ascii_lines,
2076 named_function_starts,
2077 }
2078 }
2079
2080 fn byte_position(&self, line: u32, utf16_column: u32) -> Option<IstanbulPosition> {
2081 let line_index = usize::try_from(line.checked_sub(1)?).ok()?;
2082 let line_start = *self.line_starts.get(line_index)?;
2083 let line_end = self
2084 .line_starts
2085 .get(line_index + 1)
2086 .copied()
2087 .map_or(self.source.len(), |next_start| next_start - 1);
2088 let line_source = self.source.get(line_start..line_end)?;
2089 let byte_column = if let Some(index) = self.non_ascii_lines.get(&line_index) {
2090 index.byte_column(utf16_column)?
2091 } else {
2092 let byte_column = usize::try_from(utf16_column).ok()?;
2093 (byte_column <= line_source.len()).then_some(byte_column)?
2094 };
2095 Some(IstanbulPosition::new(
2096 line,
2097 u32::try_from(byte_column).ok()?,
2098 ))
2099 }
2100
2101 fn absolute_offset(&self, line: u32, utf16_column: u32) -> Option<usize> {
2102 let position = self.byte_position(line, utf16_column)?;
2103 let line_index = usize::try_from(position.line.checked_sub(1)?).ok()?;
2104 self.line_starts
2105 .get(line_index)?
2106 .checked_add(position.col as usize)
2107 }
2108
2109 fn position_at_offset(&self, offset: usize) -> Option<IstanbulPosition> {
2110 if offset > self.source.len() {
2111 return None;
2112 }
2113 let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
2114 Some(IstanbulPosition::new(
2115 u32::try_from(line_index + 1).ok()?,
2116 u32::try_from(offset.checked_sub(self.line_starts[line_index])?).ok()?,
2117 ))
2118 }
2119
2120 fn named_function_start(
2121 &self,
2122 fn_entry: &oxc_coverage_instrument::FnEntry,
2123 ) -> Option<IstanbulPosition> {
2124 let declaration_offset =
2125 self.absolute_offset(fn_entry.decl.start.line, fn_entry.decl.start.column)?;
2126 let syntax_offset = *self.named_function_starts.get(&declaration_offset)?;
2127 self.position_at_offset(syntax_offset)
2128 }
2129}
2130
2131impl Utf16LineIndex {
2132 fn byte_column(&self, utf16_column: u32) -> Option<usize> {
2133 if utf16_column > self.utf16_len {
2134 return None;
2135 }
2136 let completed = self
2137 .checkpoints
2138 .partition_point(|checkpoint| checkpoint.utf16_end <= utf16_column);
2139 if let Some(next) = self.checkpoints.get(completed)
2140 && utf16_column > next.utf16_start
2141 {
2142 return None;
2143 }
2144 let (utf16_base, byte_base) = completed
2145 .checked_sub(1)
2146 .and_then(|index| self.checkpoints.get(index))
2147 .map_or((0, 0), |checkpoint| {
2148 (checkpoint.utf16_end, checkpoint.byte_end)
2149 });
2150 let ascii_width = usize::try_from(utf16_column.checked_sub(utf16_base)?).ok()?;
2151 let byte_column = byte_base.checked_add(ascii_width)?;
2152 (byte_column <= self.byte_len).then_some(byte_column)
2153 }
2154}
2155
2156fn utf16_line_indexes(
2157 source: &str,
2158 line_starts: &[usize],
2159) -> rustc_hash::FxHashMap<usize, Utf16LineIndex> {
2160 let mut indexes = rustc_hash::FxHashMap::default();
2161 for (line_index, &line_start) in line_starts.iter().enumerate() {
2162 let line_end = line_starts
2163 .get(line_index + 1)
2164 .copied()
2165 .map_or(source.len(), |next_start| next_start - 1);
2166 let Some(line) = source.get(line_start..line_end) else {
2167 continue;
2168 };
2169 if line.is_ascii() {
2170 continue;
2171 }
2172 let mut utf16_column = 0_u32;
2173 let mut checkpoints = Vec::new();
2174 for (byte_column, character) in line.char_indices() {
2175 let utf16_width = character.len_utf16() as u32;
2176 if !character.is_ascii() {
2177 checkpoints.push(Utf16Checkpoint {
2178 utf16_start: utf16_column,
2179 utf16_end: utf16_column.saturating_add(utf16_width),
2180 byte_end: byte_column.saturating_add(character.len_utf8()),
2181 });
2182 }
2183 utf16_column = utf16_column.saturating_add(utf16_width);
2184 }
2185 indexes.insert(
2186 line_index,
2187 Utf16LineIndex {
2188 utf16_len: utf16_column,
2189 byte_len: line.len(),
2190 checkpoints,
2191 },
2192 );
2193 }
2194 indexes
2195}
2196
2197fn named_function_starts_from_clean_parse(
2198 source: &str,
2199 path: &std::path::Path,
2200) -> rustc_hash::FxHashMap<usize, usize> {
2201 let source_type = match path.extension().and_then(|extension| extension.to_str()) {
2202 Some("gts") => oxc_span::SourceType::ts(),
2203 Some("gjs") => oxc_span::SourceType::mjs(),
2204 _ => oxc_span::SourceType::from_path(path).unwrap_or_default(),
2205 };
2206 if let Some(starts) = collect_named_function_starts(source, source_type) {
2207 return starts;
2208 }
2209 if source_type.is_jsx() {
2210 return rustc_hash::FxHashMap::default();
2211 }
2212 let jsx_source_type = if source_type.is_typescript() {
2213 oxc_span::SourceType::tsx()
2214 } else {
2215 oxc_span::SourceType::jsx()
2216 };
2217 collect_named_function_starts(source, jsx_source_type).unwrap_or_default()
2218}
2219
2220fn collect_named_function_starts(
2221 source: &str,
2222 source_type: oxc_span::SourceType,
2223) -> Option<rustc_hash::FxHashMap<usize, usize>> {
2224 fallow_extract::parse_bounded_or_default(source, source_type, |parsed| {
2225 if parsed.fatal_error || !parsed.diagnostics.is_empty() {
2226 return None;
2227 }
2228 let mut starts = rustc_hash::FxHashMap::default();
2229 let mut collector = NamedFunctionSyntaxCollector {
2230 starts: &mut starts,
2231 };
2232 oxc_ast_visit::Visit::visit_program(&mut collector, &parsed.program);
2233 Some(starts)
2234 })
2235}
2236
2237struct NamedFunctionSyntaxCollector<'a> {
2238 starts: &'a mut rustc_hash::FxHashMap<usize, usize>,
2239}
2240
2241impl<'ast> oxc_ast_visit::Visit<'ast> for NamedFunctionSyntaxCollector<'_> {
2242 fn visit_function(
2243 &mut self,
2244 function: &oxc_ast::ast::Function<'ast>,
2245 flags: oxc_syntax::scope::ScopeFlags,
2246 ) {
2247 if let Some(identifier) = &function.id
2248 && let (Ok(identifier_start), Ok(syntax_start)) = (
2249 usize::try_from(identifier.span.start),
2250 usize::try_from(function.span.start),
2251 )
2252 {
2253 self.starts.insert(identifier_start, syntax_start);
2254 }
2255 oxc_ast_visit::walk::walk_function(self, function, flags);
2256 }
2257}
2258
2259fn normalized_istanbul_position(
2260 line: u32,
2261 column: u32,
2262 source_index: Option<&IstanbulSourceIndex<'_>>,
2263) -> Option<IstanbulPosition> {
2264 match source_index {
2265 Some(index) => index.byte_position(line, column),
2266 None => Some(IstanbulPosition::new(line, column)),
2267 }
2268}
2269
2270fn named_function_syntax_alias(
2276 fn_entry: &oxc_coverage_instrument::FnEntry,
2277 source_index: Option<&IstanbulSourceIndex<'_>>,
2278) -> Option<IstanbulAlias> {
2279 if is_anonymous_istanbul_name(&fn_entry.name) {
2280 return None;
2281 }
2282 Some(IstanbulAlias {
2283 position: source_index?.named_function_start(fn_entry)?,
2284 primary: true,
2285 })
2286}
2287
2288fn effective_istanbul_fn_line(fn_entry: &oxc_coverage_instrument::FnEntry) -> u32 {
2289 if fn_entry.line > 0 {
2290 fn_entry.line
2291 } else {
2292 fn_entry.decl.start.line
2293 }
2294}
2295
2296#[derive(Default)]
2298struct StatementTally {
2299 covered: u32,
2300 total: u32,
2301}
2302
2303fn istanbul_range_contains(
2309 outer: &oxc_coverage_instrument::Location,
2310 inner: &oxc_coverage_instrument::Location,
2311) -> bool {
2312 let after_start = inner.start.line > outer.start.line
2313 || (inner.start.line == outer.start.line && inner.start.column >= outer.start.column);
2314 let before_end = inner.end.line < outer.end.line
2315 || (inner.end.line == outer.end.line && inner.end.column <= outer.end.column);
2316 after_start && before_end
2317}
2318
2319fn tally_statements_by_owner(
2339 file_cov: &oxc_coverage_instrument::FileCoverage,
2340) -> rustc_hash::FxHashMap<&str, StatementTally> {
2341 let mut owners: rustc_hash::FxHashMap<&str, StatementTally> = rustc_hash::FxHashMap::default();
2342
2343 for (stmt_id, stmt_loc) in &file_cov.statement_map {
2344 let mut owner: Option<(&str, &oxc_coverage_instrument::Location)> = None;
2345
2346 for (fn_id, fn_entry) in &file_cov.fn_map {
2347 if !istanbul_range_contains(&fn_entry.loc, stmt_loc) {
2348 continue;
2349 }
2350 let inner_than_current = owner.is_none_or(|(_, best)| {
2351 let candidate = (
2352 (fn_entry.loc.start.line, fn_entry.loc.start.column),
2353 std::cmp::Reverse((fn_entry.loc.end.line, fn_entry.loc.end.column)),
2354 );
2355 let incumbent = (
2356 (best.start.line, best.start.column),
2357 std::cmp::Reverse((best.end.line, best.end.column)),
2358 );
2359 candidate > incumbent
2360 });
2361 if inner_than_current {
2362 owner = Some((fn_id.as_str(), &fn_entry.loc));
2363 }
2364 }
2365
2366 let Some((fn_id, _)) = owner else {
2367 continue;
2368 };
2369 let tally = owners.entry(fn_id).or_default();
2370 tally.total += 1;
2371 if file_cov.s.get(stmt_id).copied().unwrap_or(0) > 0 {
2372 tally.covered += 1;
2373 }
2374 }
2375
2376 owners
2377}
2378
2379fn compute_function_statement_coverage(
2388 file_cov: &oxc_coverage_instrument::FileCoverage,
2389 fn_id: &str,
2390 owned: Option<&StatementTally>,
2391) -> f64 {
2392 match owned {
2393 Some(tally) if tally.total > 0 => f64::from(tally.covered) / f64::from(tally.total) * 100.0,
2394 _ => {
2395 let hit = file_cov.f.get(fn_id).copied().unwrap_or(0);
2396 if hit > 0 { 100.0 } else { 0.0 }
2397 }
2398 }
2399}
2400
2401fn count_unused_exports_by_path(
2406 unused_exports: &[crate::results::UnusedExportFinding],
2407) -> rustc_hash::FxHashMap<&std::path::Path, usize> {
2408 let mut map: rustc_hash::FxHashMap<&std::path::Path, usize> = rustc_hash::FxHashMap::default();
2409 for exp in unused_exports {
2410 *map.entry(exp.export.path.as_path()).or_default() += 1;
2411 }
2412 map
2413}
2414
2415fn compute_maintainability_index(
2435 complexity_density: f64,
2436 dead_code_ratio: f64,
2437 fan_out: usize,
2438 lines: u32,
2439) -> f64 {
2440 let dampening = (f64::from(lines) / fallow_output::MI_DENSITY_MIN_LINES).min(1.0);
2441 let fan_out_penalty = ((fan_out as f64).ln_1p() * 4.0).min(15.0);
2442 #[expect(
2443 clippy::suboptimal_flops,
2444 reason = "formula matches documented specification"
2445 )]
2446 let score = 100.0
2447 - (complexity_density * 30.0 * dampening)
2448 - (dead_code_ratio * 20.0)
2449 - fan_out_penalty;
2450 score.clamp(0.0, 100.0)
2451}
2452
2453fn file_score_structural_concern(score: &FileHealthScore) -> f64 {
2454 (100.0 - score.maintainability_index).clamp(0.0, 100.0)
2455}
2456
2457#[must_use]
2463pub fn file_score_fully_crap_exempt(score: &FileHealthScore, max_crap_threshold: f64) -> bool {
2464 let signal = file_score_crap_signal(score, max_crap_threshold);
2465 max_crap_threshold <= 0.0
2466 || (score.crap_above_threshold == 0
2467 && if signal.suppression_aware {
2468 signal.maximum >= CRAP_THRESHOLD
2469 } else {
2470 score.crap_exempted > 0
2471 })
2472}
2473
2474pub struct FileScoreCrapSignal {
2476 pub maximum: f64,
2478 pub threshold: f64,
2480 suppression_aware: bool,
2481}
2482
2483impl FileScoreCrapSignal {
2484 pub const fn metric(&self) -> &'static str {
2486 if self.suppression_aware {
2487 "crap_unsuppressed_max"
2488 } else {
2489 "crap_max"
2490 }
2491 }
2492}
2493
2494pub fn file_score_crap_signal(score: &FileHealthScore, global: f64) -> FileScoreCrapSignal {
2497 match (
2498 score.crap_unsuppressed_max,
2499 score.crap_unsuppressed_effective_threshold,
2500 ) {
2501 (Some(maximum), Some(threshold)) => FileScoreCrapSignal {
2502 maximum,
2503 threshold,
2504 suppression_aware: true,
2505 },
2506 _ => FileScoreCrapSignal {
2507 maximum: score.crap_max,
2508 threshold: score.crap_effective_threshold.unwrap_or(global),
2509 suppression_aware: false,
2510 },
2511 }
2512}
2513
2514fn file_score_crap_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2521 if file_score_fully_crap_exempt(score, max_crap_threshold) {
2522 return 0.0;
2523 }
2524 let signal = file_score_crap_signal(score, max_crap_threshold);
2525 let crap_max = signal.maximum;
2526 let t = signal.threshold;
2527 let half = t / 2.0;
2528 let saturation = t * 10.0 / 3.0;
2529 if crap_max <= 0.0 {
2530 0.0
2531 } else if crap_max < half {
2532 (crap_max / half) * 45.0
2533 } else if crap_max < t {
2534 ((crap_max - half) / half).mul_add(30.0, 45.0)
2535 } else if crap_max < saturation {
2536 ((crap_max - t) / (saturation - t)).mul_add(25.0, 75.0)
2537 } else {
2538 100.0
2539 }
2540}
2541
2542fn file_score_triage_concern(score: &FileHealthScore, max_crap_threshold: f64) -> f64 {
2543 file_score_structural_concern(score).max(file_score_crap_concern(score, max_crap_threshold))
2544}
2545
2546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2552pub enum FileScoreConcern {
2553 Structural,
2555 Risk,
2557}
2558
2559impl FileScoreConcern {
2560 pub const fn label(self) -> &'static str {
2562 match self {
2563 Self::Structural => "structure",
2564 Self::Risk => "risk",
2565 }
2566 }
2567}
2568
2569pub fn file_score_concern_axis(
2578 score: &FileHealthScore,
2579 max_crap_threshold: f64,
2580) -> FileScoreConcern {
2581 let crap_concern = file_score_crap_concern(score, max_crap_threshold);
2582 if crap_concern <= 0.0 {
2583 FileScoreConcern::Structural
2584 } else if crap_concern >= file_score_structural_concern(score) {
2585 FileScoreConcern::Risk
2586 } else {
2587 FileScoreConcern::Structural
2588 }
2589}
2590
2591fn compare_file_score_triage(
2592 a: &FileHealthScore,
2593 b: &FileHealthScore,
2594 max_crap_threshold: f64,
2595) -> std::cmp::Ordering {
2596 file_score_triage_concern(b, max_crap_threshold)
2597 .total_cmp(&file_score_triage_concern(a, max_crap_threshold))
2598 .then_with(|| {
2599 file_score_crap_signal(b, max_crap_threshold)
2600 .maximum
2601 .total_cmp(&file_score_crap_signal(a, max_crap_threshold).maximum)
2602 })
2603 .then_with(|| a.maintainability_index.total_cmp(&b.maintainability_index))
2604 .then_with(|| a.path.cmp(&b.path))
2605}
2606
2607#[derive(Clone, Copy)]
2610pub(super) struct FileScoreComputeInput<'a> {
2611 pub(super) modules: &'a [crate::source::ModuleInfo],
2612 pub(super) file_paths:
2613 &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2614 pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2615 pub(super) istanbul_coverage: Option<&'a IstanbulCoverage>,
2616 pub(super) root: &'a std::path::Path,
2617 pub(super) crap_thresholds: CrapScoreThresholds<'a>,
2618 pub(super) with_static_links: bool,
2621 pub(super) test_reach: Option<&'a super::test_reach::TestReachGraph>,
2624}
2625
2626pub(super) fn compute_file_scores(
2632 input: FileScoreComputeInput<'_>,
2633 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
2634) -> Result<FileScoreOutput, String> {
2635 let FileScoreComputeInput {
2636 modules,
2637 file_paths,
2638 changed_files,
2639 istanbul_coverage,
2640 root,
2641 crap_thresholds,
2642 with_static_links,
2643 test_reach,
2644 } = input;
2645 let retained_graph = analysis_output.graph.ok_or("graph not available")?;
2646 let test_coverage = test_reach.map_or_else(
2647 || retained_graph.static_test_coverage(),
2648 super::test_reach::TestReachGraph::static_test_coverage,
2649 );
2650 let graph = retained_graph.as_graph();
2651 let results = &analysis_output.results;
2652
2653 let circular_files = collect_circular_files(results);
2654 let top_complex_fns = collect_top_complex_fns(modules, file_paths);
2655 let cycle_members = collect_cycle_members(results);
2656 let direct_callers = collect_direct_callers(graph, file_paths);
2657 let unused_export_names = collect_unused_export_names(results);
2658
2659 let unused_files: rustc_hash::FxHashSet<&std::path::Path> = results
2660 .unused_files
2661 .iter()
2662 .map(|f| f.file.path.as_path())
2663 .collect();
2664
2665 let unused_exports_by_path = count_unused_exports_by_path(&results.unused_exports);
2666
2667 let FileScoreCoverageSetup {
2668 module_by_id,
2669 coverage,
2670 } = prepare_file_score_coverage_setup(modules, file_paths, results, graph, test_coverage, root);
2671
2672 let template_inherit =
2673 build_template_inherit_contexts(graph, test_coverage, &module_by_id, file_paths);
2674
2675 let mut acc = accumulate_file_scores(
2676 unused_export_names,
2677 &FileScoreLoopCtx {
2678 graph,
2679 test_coverage,
2680 file_paths,
2681 module_by_id: &module_by_id,
2682 unused_files: &unused_files,
2683 unused_exports_by_path: &unused_exports_by_path,
2684 template_inherit: &template_inherit,
2685 istanbul_coverage,
2686 root,
2687 crap_thresholds,
2688 },
2689 );
2690 acc.scores = finalize_file_score_list(
2691 acc.scores,
2692 changed_files,
2693 crap_thresholds.resolver.global.crap,
2694 );
2695
2696 Ok(build_file_score_output(FileScoreOutputParts {
2697 graph,
2698 with_static_links,
2699 file_paths,
2700 results,
2701 scores: acc.scores,
2702 coverage,
2703 circular_files,
2704 top_complex_fns,
2705 crap_target_fns: acc.crap_target_fns,
2706 entry_points: acc.entry_points,
2707 value_export_counts: acc.value_export_counts,
2708 unused_export_names: acc.unused_export_names,
2709 cycle_members,
2710 direct_callers,
2711 istanbul_matched: acc.istanbul_matched,
2712 istanbul_total: acc.istanbul_total,
2713 istanbul_files_joined: acc.istanbul_files_joined,
2714 istanbul_files_total: acc.istanbul_files_total,
2715 coverage_input_format: istanbul_coverage.map(IstanbulCoverage::format),
2716 per_function_crap: acc.per_function_crap,
2717 template_inherit,
2718 }))
2719}
2720
2721struct FileScoreLoopCtx<'a> {
2723 graph: &'a fallow_graph::graph::ModuleGraph,
2724 test_coverage: StaticTestCoverage<'a>,
2725 file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2726 module_by_id: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
2727 unused_files: &'a rustc_hash::FxHashSet<&'a std::path::Path>,
2728 unused_exports_by_path: &'a rustc_hash::FxHashMap<&'a std::path::Path, usize>,
2729 template_inherit: &'a rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2730 istanbul_coverage: Option<&'a IstanbulCoverage>,
2731 root: &'a std::path::Path,
2734 crap_thresholds: CrapScoreThresholds<'a>,
2735}
2736
2737struct FileScoreAccumulator {
2739 scores: Vec<FileHealthScore>,
2740 entry_points: rustc_hash::FxHashSet<std::path::PathBuf>,
2741 value_export_counts: rustc_hash::FxHashMap<std::path::PathBuf, usize>,
2742 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2743 crap_target_fns: rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>>,
2744 per_function_crap: rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
2745 istanbul_matched: usize,
2746 istanbul_files_joined: usize,
2747 istanbul_files_total: usize,
2748 istanbul_total: usize,
2749}
2750
2751impl FileScoreAccumulator {
2752 fn with_capacity(modules: usize) -> Self {
2754 FileScoreAccumulator {
2755 scores: Vec::with_capacity(modules),
2756 entry_points: rustc_hash::FxHashSet::default(),
2757 value_export_counts: rustc_hash::FxHashMap::default(),
2758 unused_export_names: rustc_hash::FxHashMap::default(),
2759 crap_target_fns: rustc_hash::FxHashMap::default(),
2760 per_function_crap: rustc_hash::FxHashMap::default(),
2761 istanbul_matched: 0,
2762 istanbul_total: 0,
2763 istanbul_files_joined: 0,
2764 istanbul_files_total: 0,
2765 }
2766 }
2767}
2768
2769fn accumulate_file_scores(
2772 unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
2773 ctx: &FileScoreLoopCtx<'_>,
2774) -> FileScoreAccumulator {
2775 let mut acc = FileScoreAccumulator {
2776 unused_export_names,
2777 istanbul_files_total: ctx
2778 .istanbul_coverage
2779 .map_or(0, IstanbulCoverage::file_count),
2780 ..FileScoreAccumulator::with_capacity(ctx.graph.modules.len())
2781 };
2782 for node in &ctx.graph.modules {
2783 let Some(path) = ctx.file_paths.get(&node.file_id) else {
2784 continue;
2785 };
2786 record_entry_point(&mut acc.entry_points, node, path);
2787 let score = compute_one_file_score(&mut acc, ctx, node, path);
2788 acc.scores.push(score);
2789 }
2790 acc
2791}
2792
2793fn finalize_file_score_list(
2796 mut scores: Vec<FileHealthScore>,
2797 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
2798 max_crap_threshold: f64,
2799) -> Vec<FileHealthScore> {
2800 if let Some(changed) = changed_files {
2801 scores.retain(|s| changed.contains(&s.path));
2802 }
2803 scores.retain(|s| s.function_count > 0);
2804 scores.sort_by(|a, b| compare_file_score_triage(a, b, max_crap_threshold));
2805 scores
2806}
2807
2808fn compute_one_file_score(
2810 acc: &mut FileScoreAccumulator,
2811 ctx: &FileScoreLoopCtx<'_>,
2812 node: &fallow_graph::graph::ModuleNode,
2813 path: &std::path::Path,
2814) -> FileHealthScore {
2815 let fan_in = ctx
2816 .graph
2817 .reverse_deps
2818 .get(node.file_id.0 as usize)
2819 .map_or(0, Vec::len);
2820 let fan_out = node.edge_range.len();
2821
2822 let (total_cyclomatic, total_cognitive, function_count, lines) = ctx
2823 .module_by_id
2824 .get(&node.file_id)
2825 .map_or((0, 0, 0, 0), |module| aggregate_complexity(module));
2826
2827 let value_exports = node.exports.iter().filter(|e| !e.is_type_only).count();
2828 let path_owned = path.to_path_buf();
2829 acc.value_export_counts
2830 .insert(path_owned.clone(), value_exports);
2831 record_unused_file_export_names(
2832 path_owned.as_path(),
2833 &node.exports,
2834 ctx.unused_files,
2835 &mut acc.unused_export_names,
2836 );
2837
2838 let (dead_code_ratio_rounded, complexity_density_rounded, maintainability_index_rounded) =
2839 compute_file_score_metrics(node, &path_owned, ctx, total_cyclomatic, lines, fan_out);
2840
2841 let relative = path_owned.strip_prefix(ctx.root).unwrap_or(&path_owned);
2842 let suppressions = ctx
2843 .module_by_id
2844 .get(&node.file_id)
2845 .map_or(&[][..], |module| module.suppressions.as_slice());
2846 let ceilings = CrapCeilingLookup::new(ctx.crap_thresholds, relative, suppressions);
2847 let crap = compute_file_score_crap(node, ctx, &path_owned, &ceilings);
2848 acc.istanbul_matched += crap.istanbul_matched;
2849 acc.istanbul_total += crap.istanbul_total;
2850 acc.istanbul_files_joined += usize::from(crap.coverage_file_joined);
2851 record_per_function_crap(&mut acc.per_function_crap, &path_owned, crap.per_function);
2852 let mut eligible_functions = crap.signals.eligible_functions;
2853 if !eligible_functions.is_empty() {
2854 eligible_functions.sort_by_key(|function| std::cmp::Reverse(function.2));
2855 eligible_functions.truncate(MAX_FUNCTION_EVIDENCE);
2856 acc.crap_target_fns
2857 .insert(path_owned.clone(), eligible_functions);
2858 }
2859
2860 let global_crap = ctx.crap_thresholds.resolver.global.crap;
2865 let crap_effective_threshold = crap
2866 .signals
2867 .min_ceiling
2868 .filter(|ceiling| (*ceiling - global_crap).abs() > f64::EPSILON);
2869
2870 FileHealthScore {
2871 path: path_owned,
2872 fan_in,
2873 fan_out,
2874 dead_code_ratio: dead_code_ratio_rounded,
2875 complexity_density: complexity_density_rounded,
2876 maintainability_index: maintainability_index_rounded,
2877 total_cyclomatic,
2878 total_cognitive,
2879 function_count,
2880 lines,
2881 crap_max: crap.max,
2882 crap_above_threshold: crap.signals.above,
2883 crap_exempted: crap.signals.exempted,
2884 crap_suppressed: crap.signals.suppressed,
2885 crap_unsuppressed_max: (crap.signals.suppressed > 0)
2886 .then_some(crap.signals.unsuppressed_max),
2887 crap_unsuppressed_effective_threshold: (crap.signals.suppressed > 0)
2888 .then_some(crap.signals.unsuppressed_min_ceiling.unwrap_or(global_crap)),
2889 crap_effective_threshold,
2890 }
2891}
2892
2893fn compute_file_score_metrics(
2896 node: &fallow_graph::graph::ModuleNode,
2897 path: &std::path::Path,
2898 ctx: &FileScoreLoopCtx<'_>,
2899 total_cyclomatic: u32,
2900 lines: u32,
2901 fan_out: usize,
2902) -> (f64, f64, f64) {
2903 let dead_code_ratio = compute_dead_code_ratio(
2904 path,
2905 &node.exports,
2906 ctx.unused_files,
2907 ctx.unused_exports_by_path,
2908 );
2909 let complexity_density = compute_complexity_density(total_cyclomatic, lines);
2910
2911 let dead_code_ratio_rounded = (dead_code_ratio * 100.0).round() / 100.0;
2912 let complexity_density_rounded = (complexity_density * 100.0).round() / 100.0;
2913
2914 let maintainability_index = compute_maintainability_index(
2915 complexity_density_rounded,
2916 dead_code_ratio_rounded,
2917 fan_out,
2918 lines,
2919 );
2920 (
2921 dead_code_ratio_rounded,
2922 complexity_density_rounded,
2923 (maintainability_index * 10.0).round() / 10.0,
2924 )
2925}
2926
2927fn build_file_score_output(parts: FileScoreOutputParts<'_>) -> FileScoreOutput {
2928 let total_exports: usize = parts.graph.modules.iter().map(|m| m.exports.len()).sum();
2929 let unused_deps = parts.results.unused_dependencies.len()
2930 + parts.results.unused_dev_dependencies.len()
2931 + parts.results.unused_optional_dependencies.len();
2932 let analysis_snapshot =
2933 build_analysis_counts_snapshot(parts.graph, parts.file_paths, parts.results, unused_deps);
2934 let analysis_counts =
2935 build_file_score_analysis_counts(parts.results, total_exports, unused_deps);
2936 let template_inherit_provenance =
2937 build_template_inherit_provenance(parts.template_inherit, parts.file_paths);
2938
2939 FileScoreOutput {
2940 scores: parts.scores,
2941 coverage: parts.coverage,
2942 circular_files: parts.circular_files,
2943 top_complex_fns: parts.top_complex_fns,
2944 crap_target_fns: parts.crap_target_fns,
2945 entry_points: parts.entry_points,
2946 value_export_counts: parts.value_export_counts,
2947 unused_export_names: parts.unused_export_names,
2948 cycle_members: parts.cycle_members,
2949 direct_callers: parts.direct_callers,
2950 analysis_counts,
2951 prop_drilling_chains: parts.results.prop_drilling_chains.clone(),
2952 render_fan_in: parts.results.render_fan_in.clone(),
2953 analysis_snapshot,
2954 istanbul_matched: parts.istanbul_matched,
2955 istanbul_total: parts.istanbul_total,
2956 istanbul_files_joined: parts.istanbul_files_joined,
2957 istanbul_files_total: parts.istanbul_files_total,
2958 coverage_input_format: parts.coverage_input_format,
2959 per_function_crap: parts.per_function_crap,
2960 template_inherit_provenance,
2961 static_links: parts
2962 .with_static_links
2963 .then(|| crate::change_coupling::StaticLinkIndex::from_graph(parts.graph)),
2964 }
2965}
2966
2967fn build_file_score_analysis_counts(
2968 results: &crate::results::AnalysisResults,
2969 total_exports: usize,
2970 unused_deps: usize,
2971) -> crate::vital_signs::AnalysisCounts {
2972 crate::vital_signs::AnalysisCounts {
2973 total_exports,
2974 dead_files: results.unused_files.len(),
2975 dead_exports: results.unused_exports.len() + results.unused_types.len(),
2976 unused_deps,
2977 circular_deps: results.circular_dependencies.len(),
2978 total_deps: 0usize,
2979 }
2980}
2981
2982fn build_template_inherit_provenance(
2983 template_inherit: rustc_hash::FxHashMap<crate::discover::FileId, TemplateInheritContext>,
2984 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
2985) -> rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf> {
2986 template_inherit
2987 .into_iter()
2988 .filter_map(|(file_id, ctx)| {
2989 file_paths
2990 .get(&file_id)
2991 .map(|path| ((**path).clone(), ctx.provenance_owner))
2992 })
2993 .collect()
2994}
2995
2996fn record_entry_point(
2997 entry_points: &mut rustc_hash::FxHashSet<std::path::PathBuf>,
2998 node: &fallow_graph::graph::ModuleNode,
2999 path: &std::path::Path,
3000) {
3001 if node.is_entry_point() {
3002 entry_points.insert(path.to_path_buf());
3003 }
3004}
3005
3006fn record_unused_file_export_names(
3007 path: &std::path::Path,
3008 exports: &[fallow_graph::graph::ExportSymbol],
3009 unused_files: &rustc_hash::FxHashSet<&std::path::Path>,
3010 unused_export_names: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>>,
3011) {
3012 if !unused_files.contains(path) || unused_export_names.contains_key(path) {
3013 return;
3014 }
3015
3016 let names: Vec<String> = exports
3017 .iter()
3018 .filter(|export| !export.is_type_only)
3019 .map(|export| export.name.to_string())
3020 .collect();
3021 if !names.is_empty() {
3022 unused_export_names.insert(path.to_path_buf(), names);
3023 }
3024}
3025
3026struct FileScoreCrap {
3027 max: f64,
3028 signals: CrapThresholdSignals,
3029 per_function: Vec<PerFunctionCrap>,
3030 istanbul_matched: usize,
3031 istanbul_total: usize,
3032 coverage_file_joined: bool,
3035}
3036
3037impl FileScoreCrap {
3038 fn empty() -> Self {
3039 Self {
3040 max: 0.0,
3041 signals: CrapThresholdSignals::default(),
3042 per_function: Vec::new(),
3043 istanbul_matched: 0,
3044 istanbul_total: 0,
3045 coverage_file_joined: false,
3046 }
3047 }
3048
3049 fn estimated(result: EstimatedCrapResult) -> Self {
3050 Self {
3051 max: result.max_crap,
3052 signals: result.signals,
3053 per_function: result.per_function,
3054 istanbul_matched: 0,
3055 istanbul_total: 0,
3056 coverage_file_joined: false,
3057 }
3058 }
3059
3060 fn istanbul(result: IstanbulCrapResult, coverage_file_joined: bool) -> Self {
3061 Self {
3062 max: result.max_crap,
3063 signals: result.signals,
3064 per_function: result.per_function,
3065 istanbul_matched: result.matched,
3066 istanbul_total: result.total,
3067 coverage_file_joined,
3068 }
3069 }
3070}
3071
3072fn compute_file_score_crap(
3073 node: &fallow_graph::graph::ModuleNode,
3074 ctx: &FileScoreLoopCtx<'_>,
3075 path: &std::path::Path,
3076 ceilings: &CrapCeilingLookup<'_>,
3077) -> FileScoreCrap {
3078 let Some(module) = ctx.module_by_id.get(&node.file_id).copied() else {
3079 return FileScoreCrap::empty();
3080 };
3081
3082 let is_coverage_suppressed = crate::suppress::is_file_suppressed(
3083 &module.suppressions,
3084 fallow_types::suppress::IssueKind::CoverageGaps,
3085 );
3086 let is_test_reachable = ctx.test_coverage.covers_file(node.file_id) || is_coverage_suppressed;
3087 let resolution = resolve_crap_coverage(
3088 ctx.template_inherit.get(&node.file_id),
3089 ctx.istanbul_coverage,
3090 path,
3091 );
3092 match resolution {
3093 CrapCoverageResolution::TemplateInherited(inherit_ctx) => {
3094 compute_template_inherited_crap(module, inherit_ctx, ceilings)
3095 }
3096 CrapCoverageResolution::Istanbul { file_coverage } => {
3097 compute_istanbul_file_crap(module, file_coverage, is_test_reachable, ceilings)
3098 }
3099 CrapCoverageResolution::StaticEstimated => {
3100 compute_static_file_crap(module, node, ctx.test_coverage, is_test_reachable, ceilings)
3101 }
3102 }
3103}
3104
3105fn compute_template_inherited_crap(
3106 module: &crate::source::ModuleInfo,
3107 inherit_ctx: &TemplateInheritContext,
3108 ceilings: &CrapCeilingLookup<'_>,
3109) -> FileScoreCrap {
3110 FileScoreCrap::estimated(compute_crap_scores_estimated(
3111 &module.complexity,
3112 &inherit_ctx.test_referenced_exports,
3113 inherit_ctx.is_test_reachable,
3114 fallow_output::CoverageSource::EstimatedComponentInherited,
3115 ceilings,
3116 ))
3117}
3118
3119fn compute_istanbul_file_crap(
3120 module: &crate::source::ModuleInfo,
3121 file_coverage: Option<&IstanbulFileCoverage>,
3122 is_test_reachable: bool,
3123 ceilings: &CrapCeilingLookup<'_>,
3124) -> FileScoreCrap {
3125 FileScoreCrap::istanbul(
3126 compute_crap_scores_istanbul(
3127 &module.complexity,
3128 file_coverage,
3129 is_test_reachable,
3130 ceilings,
3131 ),
3132 file_coverage.is_some(),
3133 )
3134}
3135
3136fn compute_static_file_crap(
3137 module: &crate::source::ModuleInfo,
3138 node: &fallow_graph::graph::ModuleNode,
3139 test_coverage: StaticTestCoverage<'_>,
3140 is_test_reachable: bool,
3141 ceilings: &CrapCeilingLookup<'_>,
3142) -> FileScoreCrap {
3143 let test_refs = build_test_referenced_exports(node.file_id, &node.exports, test_coverage);
3144 FileScoreCrap::estimated(compute_crap_scores_estimated(
3145 &module.complexity,
3146 &test_refs,
3147 is_test_reachable,
3148 fallow_output::CoverageSource::Estimated,
3149 ceilings,
3150 ))
3151}
3152
3153fn record_per_function_crap(
3154 per_function_crap: &mut rustc_hash::FxHashMap<std::path::PathBuf, Vec<PerFunctionCrap>>,
3155 path: &std::path::Path,
3156 per_function: Vec<PerFunctionCrap>,
3157) {
3158 if !per_function.is_empty() {
3159 per_function_crap.insert(path.to_path_buf(), per_function);
3160 }
3161}
3162
3163struct FileScoreCoverageSetup<'a> {
3164 module_by_id: rustc_hash::FxHashMap<crate::discover::FileId, &'a crate::source::ModuleInfo>,
3165 coverage: CoverageGapData,
3166}
3167
3168fn prepare_file_score_coverage_setup<'a>(
3169 modules: &'a [crate::source::ModuleInfo],
3170 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3171 results: &crate::results::AnalysisResults,
3172 graph: &fallow_graph::graph::ModuleGraph,
3173 test_coverage: StaticTestCoverage<'_>,
3174 root: &std::path::Path,
3175) -> FileScoreCoverageSetup<'a> {
3176 let module_by_id: rustc_hash::FxHashMap<_, _> =
3177 modules.iter().map(|m| (m.file_id, m)).collect();
3178 let unused_exports: rustc_hash::FxHashSet<(&std::path::Path, String)> = results
3179 .unused_exports
3180 .iter()
3181 .map(|export| {
3182 (
3183 export.export.path.as_path(),
3184 export.export.export_name.clone(),
3185 )
3186 })
3187 .collect();
3188 let coverage = compute_coverage_gaps(
3189 graph,
3190 test_coverage,
3191 file_paths,
3192 &module_by_id,
3193 &unused_exports,
3194 root,
3195 );
3196 FileScoreCoverageSetup {
3197 module_by_id,
3198 coverage,
3199 }
3200}
3201
3202fn collect_circular_files(
3203 results: &crate::results::AnalysisResults,
3204) -> rustc_hash::FxHashSet<std::path::PathBuf> {
3205 results
3206 .circular_dependencies
3207 .iter()
3208 .flat_map(|c| c.cycle.files.iter().cloned())
3209 .collect()
3210}
3211
3212fn collect_top_complex_fns(
3213 modules: &[crate::source::ModuleInfo],
3214 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3215) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<(String, u32, u16)>> {
3216 let mut top_complex_fns = rustc_hash::FxHashMap::default();
3217 for module in modules {
3218 if module.complexity.is_empty() {
3219 continue;
3220 }
3221 let Some(path) = file_paths.get(&module.file_id) else {
3222 continue;
3223 };
3224 let mut funcs: Vec<(String, u32, u16)> = module
3229 .complexity
3230 .iter()
3231 .filter(|f| !fallow_types::extract::is_synthetic_module_unit(&f.name))
3232 .map(|f| (f.name.clone(), f.line, f.cognitive))
3233 .collect();
3234 funcs.sort_by_key(|f| std::cmp::Reverse(f.2));
3235 funcs.truncate(MAX_FUNCTION_EVIDENCE);
3236 if funcs.first().is_some_and(|worst| worst.2 > 0) {
3237 top_complex_fns.insert((*path).clone(), funcs);
3238 }
3239 }
3240 top_complex_fns
3241}
3242
3243fn collect_cycle_members(
3244 results: &crate::results::AnalysisResults,
3245) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> {
3246 let mut cycle_members: rustc_hash::FxHashMap<std::path::PathBuf, Vec<std::path::PathBuf>> =
3247 rustc_hash::FxHashMap::default();
3248 for cycle in &results.circular_dependencies {
3249 for file in &cycle.cycle.files {
3250 let others: Vec<std::path::PathBuf> = cycle
3251 .cycle
3252 .files
3253 .iter()
3254 .filter(|f| *f != file)
3255 .cloned()
3256 .collect();
3257 cycle_members
3258 .entry(file.clone())
3259 .or_default()
3260 .extend(others);
3261 }
3262 }
3263 for members in cycle_members.values_mut() {
3264 members.sort();
3265 members.dedup();
3266 }
3267 cycle_members
3268}
3269
3270fn collect_unused_export_names(
3271 results: &crate::results::AnalysisResults,
3272) -> rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> {
3273 let mut unused_export_names: rustc_hash::FxHashMap<std::path::PathBuf, Vec<String>> =
3274 rustc_hash::FxHashMap::default();
3275 for exp in &results.unused_exports {
3276 unused_export_names
3277 .entry(exp.export.path.clone())
3278 .or_default()
3279 .push(exp.export.export_name.clone());
3280 }
3281 unused_export_names
3282}
3283
3284fn build_analysis_counts_snapshot(
3285 graph: &fallow_graph::graph::ModuleGraph,
3286 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3287 results: &crate::results::AnalysisResults,
3288 unused_deps: usize,
3289) -> AnalysisCountsSnapshot {
3290 let mut module_export_counts = rustc_hash::FxHashMap::with_capacity_and_hasher(
3291 graph.modules.len(),
3292 rustc_hash::FxBuildHasher,
3293 );
3294 for module in &graph.modules {
3295 if let Some(path) = file_paths.get(&module.file_id) {
3296 module_export_counts.insert((*path).clone(), module.exports.len());
3297 }
3298 }
3299
3300 let mut unused_export_paths =
3301 Vec::with_capacity(results.unused_exports.len() + results.unused_types.len());
3302 unused_export_paths.extend(results.unused_exports.iter().map(|e| e.export.path.clone()));
3303 unused_export_paths.extend(results.unused_types.iter().map(|e| e.export.path.clone()));
3304
3305 let mut unused_dep_package_paths = Vec::with_capacity(unused_deps);
3306 unused_dep_package_paths.extend(
3307 results
3308 .unused_dependencies
3309 .iter()
3310 .map(|d| d.dep.path.clone()),
3311 );
3312 unused_dep_package_paths.extend(
3313 results
3314 .unused_dev_dependencies
3315 .iter()
3316 .map(|d| d.dep.path.clone()),
3317 );
3318 unused_dep_package_paths.extend(
3319 results
3320 .unused_optional_dependencies
3321 .iter()
3322 .map(|d| d.dep.path.clone()),
3323 );
3324
3325 AnalysisCountsSnapshot {
3326 unused_file_paths: results
3327 .unused_files
3328 .iter()
3329 .map(|f| f.file.path.clone())
3330 .collect(),
3331 unused_export_paths,
3332 unused_dep_package_paths,
3333 circular_dep_groups: results
3334 .circular_dependencies
3335 .iter()
3336 .map(|c| c.cycle.files.clone())
3337 .collect(),
3338 module_export_counts,
3339 }
3340}
3341
3342#[cfg(test)]
3343#[path = "scoring_private_member_tests.rs"]
3344mod private_member_tests;
3345
3346#[cfg(test)]
3347mod tests {
3348 use super::super::threshold_overrides::GlobalHealthThresholds;
3349 use super::*;
3350
3351 fn test_crap_resolver(crap: f64) -> ThresholdOverrideResolver {
3354 ThresholdOverrideResolver::new(
3355 &[],
3356 GlobalHealthThresholds {
3357 cyclomatic: 20,
3358 cognitive: 15,
3359 crap,
3360 unit_size: 120,
3361 },
3362 )
3363 }
3364
3365 fn test_override_resolver(
3367 overrides: &[fallow_config::HealthThresholdOverride],
3368 ) -> ThresholdOverrideResolver {
3369 ThresholdOverrideResolver::new(
3370 overrides,
3371 GlobalHealthThresholds {
3372 cyclomatic: 20,
3373 cognitive: 15,
3374 crap: CRAP_THRESHOLD,
3375 unit_size: 120,
3376 },
3377 )
3378 }
3379
3380 pub(super) fn istanbul_crap_default(
3382 complexity: &[fallow_types::extract::FunctionComplexity],
3383 file_coverage: Option<&IstanbulFileCoverage>,
3384 is_test_reachable: bool,
3385 ) -> IstanbulCrapResult {
3386 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3387 let ceilings = CrapCeilingLookup::new(
3388 CrapScoreThresholds {
3389 resolver: &resolver,
3390 enforce_crap: true,
3391 },
3392 std::path::Path::new("src/test.ts"),
3393 &[],
3394 );
3395 compute_crap_scores_istanbul(complexity, file_coverage, is_test_reachable, &ceilings)
3396 }
3397
3398 #[test]
3403 fn an_unmatched_function_scores_the_same_with_and_without_a_coverage_map() {
3404 let temp = tempfile::TempDir::new().unwrap();
3405 let source_path = temp.path().join("src/grade.ts");
3406 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
3407 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
3408
3409 let coverage_path = temp.path().join("coverage-final.json");
3413 write_single_file_istanbul_fixture(
3414 &coverage_path,
3415 &source_path,
3416 &serde_json::json!({
3417 "0": {
3418 "name": "unrelated",
3419 "line": 40,
3420 "decl": { "start": { "line": 40, "column": 0 }, "end": { "line": 40, "column": 9 } },
3421 "loc": { "start": { "line": 40, "column": 20 }, "end": { "line": 44, "column": 1 } }
3422 }
3423 }),
3424 &serde_json::json!({ "0": 1 }),
3425 );
3426 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
3427 let canonical_source = dunce::canonicalize(&source_path).unwrap();
3428 let file_coverage = coverage.get(&canonical_source).unwrap();
3429
3430 let function = make_fn_complexity(10);
3431 let with_map =
3432 istanbul_crap_default(std::slice::from_ref(&function), Some(file_coverage), true);
3433 let estimated = compute_crap_scores_estimated(
3434 std::slice::from_ref(&function),
3435 &rustc_hash::FxHashSet::default(),
3436 true,
3437 fallow_output::CoverageSource::Estimated,
3438 &CrapCeilingLookup::new(
3439 CrapScoreThresholds {
3440 resolver: &test_crap_resolver(CRAP_THRESHOLD),
3441 enforce_crap: true,
3442 },
3443 std::path::Path::new("src/test.ts"),
3444 &[],
3445 ),
3446 );
3447
3448 assert_eq!(with_map.matched, 0);
3449 assert!(
3450 (with_map.per_function[0].crap - estimated.per_function[0].crap).abs() < f64::EPSILON,
3451 "a map that attributes nothing must not change the score"
3452 );
3453 assert_eq!(with_map.per_function[0].coverage_pct, None);
3454 }
3455
3456 fn test_istanbul_file_coverage(
3457 functions: rustc_hash::FxHashMap<(String, u32, u32), f64>,
3458 relocated: bool,
3459 ) -> IstanbulFileCoverage {
3460 let functions = functions
3461 .into_iter()
3462 .map(
3463 |((name, line, col), coverage_pct)| IstanbulFunctionCoverage {
3464 name,
3465 coverage_pct,
3466 aliases: vec![primary_alias(line, col)],
3467 decl_start: IstanbulPosition::new(line, col),
3468 header_holds_other_fn: false,
3469 header_span: None,
3470 body_span: None,
3471 },
3472 )
3473 .collect();
3474 IstanbulFileCoverage::new(functions, relocated)
3475 }
3476
3477 fn primary_alias(line: u32, col: u32) -> IstanbulAlias {
3478 IstanbulAlias {
3479 position: IstanbulPosition::new(line, col),
3480 primary: true,
3481 }
3482 }
3483
3484 fn secondary_alias(line: u32, col: u32) -> IstanbulAlias {
3485 IstanbulAlias {
3486 position: IstanbulPosition::new(line, col),
3487 primary: false,
3488 }
3489 }
3490
3491 fn body_span(start: (u32, u32), end: (u32, u32)) -> IstanbulSpan {
3492 IstanbulSpan {
3493 start: IstanbulPosition::new(start.0, start.1),
3494 end: IstanbulPosition::new(end.0, end.1),
3495 }
3496 }
3497
3498 fn estimated_crap_default(
3500 complexity: &[fallow_types::extract::FunctionComplexity],
3501 test_referenced_exports: &rustc_hash::FxHashSet<String>,
3502 is_test_reachable: bool,
3503 coverage_source: fallow_output::CoverageSource,
3504 ) -> EstimatedCrapResult {
3505 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3506 let ceilings = CrapCeilingLookup::new(
3507 CrapScoreThresholds {
3508 resolver: &resolver,
3509 enforce_crap: true,
3510 },
3511 std::path::Path::new("src/test.ts"),
3512 &[],
3513 );
3514 compute_crap_scores_estimated(
3515 complexity,
3516 test_referenced_exports,
3517 is_test_reachable,
3518 coverage_source,
3519 &ceilings,
3520 )
3521 }
3522
3523 fn compute_file_scores_default(
3525 modules: &[crate::source::ModuleInfo],
3526 file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
3527 changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3528 analysis_output: crate::results::DeadCodeAnalysisArtifacts,
3529 istanbul_coverage: Option<&IstanbulCoverage>,
3530 root: &std::path::Path,
3531 ) -> Result<FileScoreOutput, String> {
3532 let resolver = test_crap_resolver(CRAP_THRESHOLD);
3533 compute_file_scores(
3534 FileScoreComputeInput {
3535 modules,
3536 file_paths,
3537 changed_files,
3538 istanbul_coverage,
3539 root,
3540 crap_thresholds: CrapScoreThresholds {
3541 resolver: &resolver,
3542 enforce_crap: true,
3543 },
3544 with_static_links: false,
3545 test_reach: None,
3546 },
3547 analysis_output,
3548 )
3549 }
3550
3551 #[test]
3552 fn maintainability_perfect_score() {
3553 assert!((compute_maintainability_index(0.0, 0.0, 0, 100) - 100.0).abs() < f64::EPSILON);
3554 }
3555
3556 #[test]
3557 fn crap_resolution_prefers_template_inheritance_over_istanbul() {
3558 let inherit_ctx = TemplateInheritContext {
3559 is_test_reachable: true,
3560 test_referenced_exports: rustc_hash::FxHashSet::default(),
3561 provenance_owner: std::path::PathBuf::from("/project/src/app.component.ts"),
3562 };
3563 let istanbul = IstanbulCoverage {
3564 files: rustc_hash::FxHashMap::default(),
3565 format: fallow_output::CoverageInputFormat::Istanbul,
3566 };
3567
3568 let resolution = resolve_crap_coverage(
3569 Some(&inherit_ctx),
3570 Some(&istanbul),
3571 std::path::Path::new("/project/src/app.component.html"),
3572 );
3573
3574 assert!(matches!(
3575 resolution,
3576 CrapCoverageResolution::TemplateInherited(_)
3577 ));
3578 }
3579
3580 #[test]
3581 fn crap_resolution_keeps_istanbul_when_file_is_missing() {
3582 let istanbul = IstanbulCoverage {
3583 files: rustc_hash::FxHashMap::default(),
3584 format: fallow_output::CoverageInputFormat::Istanbul,
3585 };
3586
3587 let resolution = resolve_crap_coverage(
3588 None,
3589 Some(&istanbul),
3590 std::path::Path::new("/project/src/missing.ts"),
3591 );
3592
3593 assert!(matches!(
3594 resolution,
3595 CrapCoverageResolution::Istanbul {
3596 file_coverage: None
3597 }
3598 ));
3599 }
3600
3601 #[test]
3602 fn maintainability_clamped_at_zero() {
3603 assert!((compute_maintainability_index(10.0, 1.0, 100, 200) - 0.0).abs() < f64::EPSILON);
3604 }
3605
3606 #[test]
3607 fn maintainability_formula_correct() {
3608 let result = compute_maintainability_index(0.5, 0.3, 10, 100);
3609 let expected = 11.0_f64.ln().mul_add(-4.0, 100.0 - 15.0 - 6.0);
3610 assert!((result - expected).abs() < 0.01);
3611 }
3612
3613 #[test]
3614 fn maintainability_dead_file_penalty() {
3615 let result = compute_maintainability_index(0.0, 1.0, 0, 100);
3616 assert!((result - 80.0).abs() < f64::EPSILON);
3617 }
3618
3619 #[test]
3620 fn maintainability_fan_out_is_logarithmic() {
3621 let result_10 = compute_maintainability_index(0.0, 0.0, 10, 100);
3622 let result_100 = compute_maintainability_index(0.0, 0.0, 100, 100);
3623 let result_200 = compute_maintainability_index(0.0, 0.0, 200, 100);
3624
3625 assert!(result_10 > 90.0); assert!(result_100 > 84.0); assert!((result_100 - result_200).abs() < f64::EPSILON);
3628 }
3629
3630 #[test]
3631 fn maintainability_fan_out_capped_at_15() {
3632 let result = compute_maintainability_index(0.0, 1.0, 1000, 100);
3633 assert!((result - 65.0).abs() < f64::EPSILON);
3634 }
3635
3636 #[test]
3637 fn maintainability_small_file_dampened() {
3638 let small = compute_maintainability_index(0.40, 0.0, 0, 5);
3639 assert!((small - 98.8).abs() < 0.01);
3640 }
3641
3642 #[test]
3643 fn maintainability_large_file_undampened() {
3644 let large = compute_maintainability_index(0.30, 0.0, 0, 192);
3645 assert!((large - 91.0).abs() < 0.01);
3646 }
3647
3648 #[test]
3649 fn maintainability_small_file_ranks_better_than_complex_large_file() {
3650 let trivial = compute_maintainability_index(0.40, 0.0, 0, 5);
3651 let nightmare = compute_maintainability_index(0.30, 0.0, 0, 192);
3652 assert!(
3653 trivial > nightmare,
3654 "trivial file ({trivial}) should rank better than nightmare ({nightmare})"
3655 );
3656 }
3657
3658 #[test]
3659 fn maintainability_at_dampening_boundary() {
3660 let at_boundary = compute_maintainability_index(0.5, 0.0, 0, 50);
3661 let above_boundary = compute_maintainability_index(0.5, 0.0, 0, 51);
3662 assert!((at_boundary - above_boundary).abs() < 0.01);
3663 }
3664
3665 #[test]
3666 fn maintainability_zero_lines_zero_density_penalty() {
3667 let result = compute_maintainability_index(5.0, 0.0, 0, 0);
3668 assert!((result - 100.0).abs() < f64::EPSILON);
3669 }
3670
3671 #[test]
3672 fn complexity_density_zero_lines() {
3673 assert!((compute_complexity_density(10, 0)).abs() < f64::EPSILON);
3674 }
3675
3676 #[test]
3677 fn complexity_density_normal() {
3678 let result = compute_complexity_density(10, 100);
3679 assert!((result - 0.1).abs() < f64::EPSILON);
3680 }
3681
3682 #[test]
3683 fn complexity_density_high() {
3684 let result = compute_complexity_density(50, 10);
3685 assert!((result - 5.0).abs() < f64::EPSILON);
3686 }
3687
3688 #[test]
3689 fn dead_code_ratio_no_exports() {
3690 let unused_files = rustc_hash::FxHashSet::default();
3691 let unused_map = rustc_hash::FxHashMap::default();
3692 let path = std::path::Path::new("/src/foo.ts");
3693 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3694
3695 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3696 assert!((ratio).abs() < f64::EPSILON);
3697 }
3698
3699 #[test]
3700 fn dead_code_ratio_all_unused_file() {
3701 let mut unused_files: rustc_hash::FxHashSet<&std::path::Path> =
3702 rustc_hash::FxHashSet::default();
3703 let path = std::path::Path::new("/src/foo.ts");
3704 unused_files.insert(path);
3705 let unused_map = rustc_hash::FxHashMap::default();
3706 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
3707
3708 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3709 assert!((ratio - 1.0).abs() < f64::EPSILON);
3710 }
3711
3712 #[test]
3713 fn dead_code_ratio_mix() {
3714 let unused_files = rustc_hash::FxHashSet::default();
3715 let path = std::path::Path::new("/src/foo.ts");
3716
3717 let exports = vec![
3718 fallow_graph::graph::ExportSymbol {
3719 name: crate::source::ExportName::Named("a".into()),
3720 is_type_only: false,
3721 is_side_effect_used: false,
3722 visibility: crate::source::VisibilityTag::None,
3723 expected_unused_reason: None,
3724 span: oxc_span::Span::empty(0),
3725 references: vec![],
3726 reference_paths: Vec::new(),
3727 members: vec![],
3728 deprecated: false,
3729 deprecated_reason: None,
3730 },
3731 fallow_graph::graph::ExportSymbol {
3732 name: crate::source::ExportName::Named("b".into()),
3733 is_type_only: false,
3734 is_side_effect_used: false,
3735 visibility: crate::source::VisibilityTag::None,
3736 expected_unused_reason: None,
3737 span: oxc_span::Span::empty(0),
3738 references: vec![],
3739 reference_paths: Vec::new(),
3740 members: vec![],
3741 deprecated: false,
3742 deprecated_reason: None,
3743 },
3744 fallow_graph::graph::ExportSymbol {
3745 name: crate::source::ExportName::Named("c".into()),
3746 is_type_only: false,
3747 is_side_effect_used: false,
3748 visibility: crate::source::VisibilityTag::None,
3749 expected_unused_reason: None,
3750 span: oxc_span::Span::empty(0),
3751 references: vec![],
3752 reference_paths: Vec::new(),
3753 members: vec![],
3754 deprecated: false,
3755 deprecated_reason: None,
3756 },
3757 fallow_graph::graph::ExportSymbol {
3758 name: crate::source::ExportName::Named("MyType".into()),
3759 is_type_only: true,
3760 is_side_effect_used: false,
3761 visibility: crate::source::VisibilityTag::None,
3762 expected_unused_reason: None,
3763 span: oxc_span::Span::empty(0),
3764 references: vec![],
3765 reference_paths: Vec::new(),
3766 members: vec![],
3767 deprecated: false,
3768 deprecated_reason: None,
3769 },
3770 ];
3771
3772 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3773 rustc_hash::FxHashMap::default();
3774 unused_map.insert(path, 2);
3775
3776 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3777 assert!((ratio - 2.0 / 3.0).abs() < 1e-10);
3778 }
3779
3780 #[test]
3781 fn dead_code_ratio_all_type_only_exports() {
3782 let unused_files = rustc_hash::FxHashSet::default();
3783 let path = std::path::Path::new("/src/types.ts");
3784
3785 let exports = vec![fallow_graph::graph::ExportSymbol {
3786 name: crate::source::ExportName::Named("Foo".into()),
3787 is_type_only: true,
3788 is_side_effect_used: false,
3789 visibility: crate::source::VisibilityTag::None,
3790 expected_unused_reason: None,
3791 span: oxc_span::Span::empty(0),
3792 references: vec![],
3793 reference_paths: Vec::new(),
3794 members: vec![],
3795 deprecated: false,
3796 deprecated_reason: None,
3797 }];
3798 let unused_map = rustc_hash::FxHashMap::default();
3799
3800 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3801 assert!((ratio).abs() < f64::EPSILON);
3802 }
3803
3804 #[test]
3805 fn aggregate_complexity_empty_module() {
3806 let module = crate::source::ModuleInfo::empty(crate::discover::FileId(0));
3807
3808 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3809 assert_eq!(cyc, 0);
3810 assert_eq!(cog, 0);
3811 assert_eq!(funcs, 0);
3812 assert_eq!(lines, 0);
3813 }
3814
3815 #[test]
3816 fn aggregate_complexity_single_function() {
3817 let module = crate::source::ModuleInfo {
3818 line_offsets: vec![0, 10, 20, 30, 40], complexity: vec![fallow_types::extract::FunctionComplexity {
3820 name: "doStuff".into(),
3821 is_private_member: false,
3822 context_named: None,
3823 line: 1,
3824 col: 0,
3825 cyclomatic: 7,
3826 cognitive: 4,
3827 line_count: 5,
3828 param_count: 0,
3829 react_hook_count: 0,
3830 react_jsx_max_depth: 0,
3831 react_prop_count: 0,
3832 source_hash: None,
3833 contributions: Vec::new(),
3834 }],
3835 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3836 };
3837
3838 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3839 assert_eq!(cyc, 7);
3840 assert_eq!(cog, 4);
3841 assert_eq!(funcs, 1);
3842 assert_eq!(lines, 5);
3843 }
3844
3845 #[test]
3846 fn aggregate_complexity_multiple_functions() {
3847 let module = crate::source::ModuleInfo {
3848 line_offsets: vec![0, 10, 20], complexity: vec![
3850 fallow_types::extract::FunctionComplexity {
3851 name: "a".into(),
3852 is_private_member: false,
3853 context_named: None,
3854 line: 1,
3855 col: 0,
3856 cyclomatic: 3,
3857 cognitive: 2,
3858 line_count: 1,
3859 param_count: 0,
3860 react_hook_count: 0,
3861 react_jsx_max_depth: 0,
3862 react_prop_count: 0,
3863 source_hash: None,
3864 contributions: Vec::new(),
3865 },
3866 fallow_types::extract::FunctionComplexity {
3867 name: "b".into(),
3868 is_private_member: false,
3869 context_named: None,
3870 line: 2,
3871 col: 0,
3872 cyclomatic: 5,
3873 cognitive: 8,
3874 line_count: 2,
3875 param_count: 0,
3876 react_hook_count: 0,
3877 react_jsx_max_depth: 0,
3878 react_prop_count: 0,
3879 source_hash: None,
3880 contributions: Vec::new(),
3881 },
3882 ],
3883 ..crate::source::ModuleInfo::empty(crate::discover::FileId(0))
3884 };
3885
3886 let (cyc, cog, funcs, lines) = aggregate_complexity(&module);
3887 assert_eq!(cyc, 8);
3888 assert_eq!(cog, 10);
3889 assert_eq!(funcs, 2);
3890 assert_eq!(lines, 3);
3891 }
3892
3893 #[test]
3894 fn count_unused_exports_empty() {
3895 let exports: Vec<crate::results::UnusedExportFinding> = vec![];
3896 let map = count_unused_exports_by_path(&exports);
3897 assert!(map.is_empty());
3898 }
3899
3900 #[test]
3901 fn count_unused_exports_groups_by_path() {
3902 let exports = vec![
3903 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3904 path: std::path::PathBuf::from("/src/a.ts"),
3905 export_name: "foo".into(),
3906 is_type_only: false,
3907 line: 1,
3908 col: 0,
3909 span_start: 0,
3910 is_re_export: false,
3911 deprecated: false,
3912 deprecated_reason: None,
3913 }),
3914 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3915 path: std::path::PathBuf::from("/src/a.ts"),
3916 export_name: "bar".into(),
3917 is_type_only: false,
3918 line: 5,
3919 col: 0,
3920 span_start: 40,
3921 is_re_export: false,
3922 deprecated: false,
3923 deprecated_reason: None,
3924 }),
3925 crate::results::UnusedExportFinding::with_actions(crate::results::UnusedExport {
3926 path: std::path::PathBuf::from("/src/b.ts"),
3927 export_name: "baz".into(),
3928 is_type_only: false,
3929 line: 1,
3930 col: 0,
3931 span_start: 0,
3932 is_re_export: false,
3933 deprecated: false,
3934 deprecated_reason: None,
3935 }),
3936 ];
3937 let map = count_unused_exports_by_path(&exports);
3938 assert_eq!(map.get(std::path::Path::new("/src/a.ts")).copied(), Some(2));
3939 assert_eq!(map.get(std::path::Path::new("/src/b.ts")).copied(), Some(1));
3940 }
3941
3942 #[test]
3943 fn dead_code_ratio_all_value_exports_unused() {
3944 let unused_files = rustc_hash::FxHashSet::default();
3945 let path = std::path::Path::new("/src/foo.ts");
3946
3947 let exports = vec![
3948 fallow_graph::graph::ExportSymbol {
3949 name: crate::source::ExportName::Named("a".into()),
3950 is_type_only: false,
3951 is_side_effect_used: false,
3952 visibility: crate::source::VisibilityTag::None,
3953 expected_unused_reason: None,
3954 span: oxc_span::Span::empty(0),
3955 references: vec![],
3956 reference_paths: Vec::new(),
3957 members: vec![],
3958 deprecated: false,
3959 deprecated_reason: None,
3960 },
3961 fallow_graph::graph::ExportSymbol {
3962 name: crate::source::ExportName::Named("b".into()),
3963 is_type_only: false,
3964 is_side_effect_used: false,
3965 visibility: crate::source::VisibilityTag::None,
3966 expected_unused_reason: None,
3967 span: oxc_span::Span::empty(0),
3968 references: vec![],
3969 reference_paths: Vec::new(),
3970 members: vec![],
3971 deprecated: false,
3972 deprecated_reason: None,
3973 },
3974 ];
3975
3976 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
3977 rustc_hash::FxHashMap::default();
3978 unused_map.insert(path, 2);
3979
3980 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
3981 assert!((ratio - 1.0).abs() < f64::EPSILON);
3982 }
3983
3984 #[test]
3985 fn dead_code_ratio_clamped_when_unused_exceeds_value_exports() {
3986 let unused_files = rustc_hash::FxHashSet::default();
3987 let path = std::path::Path::new("/src/foo.ts");
3988
3989 let exports = vec![fallow_graph::graph::ExportSymbol {
3990 name: crate::source::ExportName::Named("a".into()),
3991 is_type_only: false,
3992 is_side_effect_used: false,
3993 visibility: crate::source::VisibilityTag::None,
3994 expected_unused_reason: None,
3995 span: oxc_span::Span::empty(0),
3996 references: vec![],
3997 reference_paths: Vec::new(),
3998 members: vec![],
3999 deprecated: false,
4000 deprecated_reason: None,
4001 }];
4002
4003 let mut unused_map: rustc_hash::FxHashMap<&std::path::Path, usize> =
4004 rustc_hash::FxHashMap::default();
4005 unused_map.insert(path, 5);
4006
4007 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
4008 assert!((ratio - 1.0).abs() < f64::EPSILON);
4009 }
4010
4011 #[test]
4012 fn dead_code_ratio_no_unused_exports_for_path() {
4013 let unused_files = rustc_hash::FxHashSet::default();
4014 let path = std::path::Path::new("/src/clean.ts");
4015
4016 let exports = vec![fallow_graph::graph::ExportSymbol {
4017 name: crate::source::ExportName::Named("used".into()),
4018 is_type_only: false,
4019 is_side_effect_used: false,
4020 visibility: crate::source::VisibilityTag::None,
4021 expected_unused_reason: None,
4022 span: oxc_span::Span::empty(0),
4023 references: vec![],
4024 reference_paths: Vec::new(),
4025 members: vec![],
4026 deprecated: false,
4027 deprecated_reason: None,
4028 }];
4029
4030 let unused_map = rustc_hash::FxHashMap::default();
4031 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_map);
4032 assert!(ratio.abs() < f64::EPSILON);
4033 }
4034
4035 #[test]
4036 fn complexity_density_zero_cyclomatic_with_lines() {
4037 let result = compute_complexity_density(0, 100);
4038 assert!(result.abs() < f64::EPSILON);
4039 }
4040
4041 #[test]
4042 fn complexity_density_single_line() {
4043 let result = compute_complexity_density(1, 1);
4044 assert!((result - 1.0).abs() < f64::EPSILON);
4045 }
4046
4047 #[test]
4048 fn maintainability_only_complexity_penalty() {
4049 let result = compute_maintainability_index(3.0, 0.0, 0, 100);
4050 assert!((result - 10.0).abs() < f64::EPSILON);
4051 }
4052
4053 #[test]
4054 fn maintainability_only_dead_code_penalty() {
4055 let result = compute_maintainability_index(0.0, 0.5, 0, 100);
4056 assert!((result - 90.0).abs() < f64::EPSILON);
4057 }
4058
4059 #[test]
4060 fn maintainability_fan_out_one() {
4061 let result = compute_maintainability_index(0.0, 0.0, 1, 100);
4062 let expected = 2.0_f64.ln().mul_add(-4.0, 100.0);
4063 assert!((result - expected).abs() < 0.01);
4064 }
4065
4066 #[test]
4067 fn maintainability_all_penalties_maxed() {
4068 let result = compute_maintainability_index(10.0, 1.0, 1000, 200);
4069 assert!(result.abs() < f64::EPSILON);
4070 }
4071
4072 #[test]
4073 fn count_unused_exports_single_file_single_export() {
4074 let exports = vec![crate::results::UnusedExportFinding::with_actions(
4075 crate::results::UnusedExport {
4076 path: std::path::PathBuf::from("/src/only.ts"),
4077 export_name: "lonely".into(),
4078 is_type_only: false,
4079 line: 1,
4080 col: 0,
4081 span_start: 0,
4082 is_re_export: false,
4083 deprecated: false,
4084 deprecated_reason: None,
4085 },
4086 )];
4087 let map = count_unused_exports_by_path(&exports);
4088 assert_eq!(map.len(), 1);
4089 assert_eq!(
4090 map.get(std::path::Path::new("/src/only.ts")).copied(),
4091 Some(1)
4092 );
4093 }
4094
4095 fn build_test_graph(
4097 files: &[crate::discover::DiscoveredFile],
4098 entry_point_paths: &[std::path::PathBuf],
4099 resolved_modules: &[fallow_graph::resolve::ResolvedModule],
4100 ) -> fallow_graph::graph::ModuleGraph {
4101 let entry_points: Vec<crate::discover::EntryPoint> = entry_point_paths
4102 .iter()
4103 .map(|p| crate::discover::EntryPoint {
4104 path: p.clone(),
4105 source: crate::discover::EntryPointSource::PackageJsonMain,
4106 })
4107 .collect();
4108 fallow_graph::graph::ModuleGraph::build(resolved_modules, &entry_points, files)
4109 }
4110
4111 fn make_module_info(
4113 file_id: u32,
4114 line_count: usize,
4115 functions: Vec<fallow_types::extract::FunctionComplexity>,
4116 ) -> crate::source::ModuleInfo {
4117 crate::source::ModuleInfo {
4118 line_offsets: (0..line_count).map(|i| (i * 10) as u32).collect(),
4119 complexity: functions,
4120 ..crate::source::ModuleInfo::empty(crate::discover::FileId(file_id))
4121 }
4122 }
4123
4124 fn make_file_score(path: &str, maintainability_index: f64, crap_max: f64) -> FileHealthScore {
4125 FileHealthScore {
4126 path: std::path::PathBuf::from(path),
4127 fan_in: 0,
4128 fan_out: 0,
4129 dead_code_ratio: 0.0,
4130 complexity_density: 0.0,
4131 maintainability_index,
4132 total_cyclomatic: 0,
4133 total_cognitive: 0,
4134 function_count: 1,
4135 lines: 1,
4136 crap_max,
4137 crap_above_threshold: usize::from(crap_max >= CRAP_THRESHOLD),
4138 crap_exempted: 0,
4139 crap_suppressed: 0,
4140 crap_unsuppressed_max: None,
4141 crap_unsuppressed_effective_threshold: None,
4142 crap_effective_threshold: None,
4143 }
4144 }
4145
4146 #[test]
4147 fn suppressed_crap_keeps_unsuppressed_bands_and_legacy_fallback() {
4148 for (remaining, threshold, above, exempted, global, expected) in [
4149 (20.0, 30.0, 0, 1, 30.0, 55.0),
4150 (20.0, 30.0, 0, 0, 30.0, 55.0),
4151 (0.0, 30.0, 0, 0, 30.0, 0.0),
4152 (110.0, 500.0, 0, 1, 30.0, 0.0),
4153 (42.0, 0.0, 0, 2, 0.0, 0.0),
4154 (20.0, 5.0, 1, 0, 5.0, 100.0),
4155 ] {
4156 let mut score = make_file_score("src/legacy.ts", 100.0, 210.0);
4157 score.crap_suppressed = 1;
4158 score.crap_above_threshold = above;
4159 score.crap_exempted = exempted;
4160 score.crap_effective_threshold = Some(5.0);
4161 score.crap_unsuppressed_max = Some(remaining);
4162 score.crap_unsuppressed_effective_threshold = Some(threshold);
4163 let restored: FileHealthScore =
4164 serde_json::from_value(serde_json::to_value(&score).unwrap()).unwrap();
4165 assert!((file_score_crap_concern(&restored, global) - expected).abs() < f64::EPSILON);
4166 assert_eq!(restored.crap_max, 210.0);
4167 assert_eq!(restored.crap_effective_threshold, Some(5.0));
4168 assert_eq!(restored.crap_exempted, exempted);
4169 }
4170 let mut legacy = make_file_score("src/legacy.ts", 100.0, 20.0);
4171 legacy.crap_effective_threshold = Some(10.0);
4172 for (maximum, threshold) in [(None, None), (Some(0.0), None), (None, Some(500.0))] {
4173 legacy.crap_unsuppressed_max = maximum;
4174 legacy.crap_unsuppressed_effective_threshold = threshold;
4175 let restored: FileHealthScore =
4176 serde_json::from_value(serde_json::to_value(&legacy).unwrap()).unwrap();
4177 let signal = file_score_crap_signal(&restored, 30.0);
4178 assert_eq!(signal.maximum, 20.0);
4179 assert_eq!(signal.threshold, 10.0);
4180 assert_eq!(signal.metric(), "crap_max");
4181 assert!(
4182 (file_score_crap_concern(&restored, 30.0) - 85.714_285_714_285_71).abs() < 1e-10
4183 );
4184 }
4185 }
4186
4187 #[test]
4188 fn suppressed_crap_maximum_does_not_win_the_triage_tie_break() {
4189 let mut suppressed = make_file_score("src/a.ts", 0.0, 210.0);
4190 suppressed.crap_suppressed = 1;
4191 suppressed.crap_unsuppressed_max = Some(42.0);
4192 suppressed.crap_unsuppressed_effective_threshold = Some(30.0);
4193 let other = make_file_score("src/b.ts", 0.0, 50.0);
4194 assert_eq!(
4195 compare_file_score_triage(&suppressed, &other, 30.0),
4196 std::cmp::Ordering::Greater
4197 );
4198 suppressed.crap_unsuppressed_max = Some(60.0);
4199 assert_eq!(
4200 compare_file_score_triage(&suppressed, &other, 30.0),
4201 std::cmp::Ordering::Less
4202 );
4203 }
4204
4205 #[test]
4206 fn inherited_crap_suppression_keeps_raw_function_measurements() {
4207 let mut module = make_module_info(
4208 0,
4209 30,
4210 vec![
4211 make_named_fn_complexity("suppressed", 1, 14),
4212 make_named_fn_complexity("remaining", 20, 6),
4213 ],
4214 );
4215 let resolver = test_crap_resolver(30.0);
4216 let context = TemplateInheritContext {
4217 is_test_reachable: false,
4218 test_referenced_exports: rustc_hash::FxHashSet::default(),
4219 provenance_owner: std::path::PathBuf::from("src/owner.ts"),
4220 };
4221 for (suppressed, expected_above) in [(false, 2), (true, 1)] {
4222 module.suppressions = if suppressed {
4223 vec![crate::suppress::Suppression::issue(
4224 1,
4225 1,
4226 crate::suppress::IssueKind::Complexity,
4227 )]
4228 } else {
4229 Vec::new()
4230 };
4231 let lookup = CrapCeilingLookup::new(
4232 CrapScoreThresholds {
4233 resolver: &resolver,
4234 enforce_crap: true,
4235 },
4236 std::path::Path::new("src/template.html"),
4237 &module.suppressions,
4238 );
4239 let result = compute_template_inherited_crap(&module, &context, &lookup);
4240 assert_eq!(result.max, 210.0);
4241 assert_eq!(result.signals.above, expected_above);
4242 assert_eq!(result.signals.suppressed, usize::from(suppressed));
4243 assert_eq!(
4244 result.signals.unsuppressed_max,
4245 if suppressed { 42.0 } else { 210.0 }
4246 );
4247 let raw = result
4248 .per_function
4249 .iter()
4250 .map(|function| (function.line, function.crap))
4251 .collect::<Vec<_>>();
4252 assert_eq!(raw, [(1, 210.0), (20, 42.0)]);
4253 assert!(
4254 result
4255 .per_function
4256 .iter()
4257 .all(|function| function.coverage_source
4258 == fallow_output::CoverageSource::EstimatedComponentInherited)
4259 );
4260 }
4261 }
4262
4263 fn crap_concern_at_default(crap_max: f64) -> f64 {
4264 file_score_crap_concern(
4265 &make_file_score("/src/concern.ts", 100.0, crap_max),
4266 CRAP_THRESHOLD,
4267 )
4268 }
4269
4270 #[test]
4271 fn file_score_crap_concern_tracks_crap_risk_bands() {
4272 assert!((crap_concern_at_default(0.0) - 0.0).abs() < f64::EPSILON);
4273 assert!((crap_concern_at_default(15.0) - 45.0).abs() < f64::EPSILON);
4274 assert!((crap_concern_at_default(CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
4275 assert!((crap_concern_at_default(100.0) - 100.0).abs() < f64::EPSILON);
4276 assert!((crap_concern_at_default(552.0) - 100.0).abs() < f64::EPSILON);
4277 }
4278
4279 #[test]
4280 fn file_score_crap_concern_generalizes_bands_over_effective_ceiling() {
4281 let mut at_edge = make_file_score("/src/edge.ts", 100.0, 250.0);
4284 at_edge.crap_above_threshold = 1;
4285 at_edge.crap_effective_threshold = Some(500.0);
4286 assert!((file_score_crap_concern(&at_edge, CRAP_THRESHOLD) - 45.0).abs() < f64::EPSILON);
4287
4288 let mut at_ceiling = make_file_score("/src/ceiling.ts", 100.0, 500.0);
4289 at_ceiling.crap_above_threshold = 1;
4290 at_ceiling.crap_effective_threshold = Some(500.0);
4291 assert!((file_score_crap_concern(&at_ceiling, CRAP_THRESHOLD) - 75.0).abs() < f64::EPSILON);
4292 }
4293
4294 #[test]
4295 fn file_score_crap_concern_zeroes_fully_exempt_file() {
4296 let mut exempt = make_file_score("/src/legacy.ts", 88.0, 110.0);
4301 exempt.crap_above_threshold = 0;
4302 exempt.crap_exempted = 2;
4303 exempt.crap_effective_threshold = Some(500.0);
4304 assert!((file_score_crap_concern(&exempt, CRAP_THRESHOLD) - 0.0).abs() < f64::EPSILON);
4305 assert!(file_score_fully_crap_exempt(&exempt, CRAP_THRESHOLD));
4306 assert_eq!(
4307 file_score_concern_axis(&exempt, CRAP_THRESHOLD),
4308 FileScoreConcern::Structural
4309 );
4310 }
4311
4312 #[test]
4313 fn file_score_crap_concern_zeroes_when_enforcement_disabled() {
4314 let mut score = make_file_score("/src/any.ts", 88.0, 110.0);
4315 score.crap_above_threshold = 0;
4316 score.crap_exempted = 2;
4317 assert!((file_score_crap_concern(&score, 0.0) - 0.0).abs() < f64::EPSILON);
4318 assert!(file_score_fully_crap_exempt(&score, 0.0));
4319 assert_eq!(
4320 file_score_concern_axis(&score, 0.0),
4321 FileScoreConcern::Structural
4322 );
4323 }
4324
4325 #[test]
4326 fn file_score_partial_exemption_keeps_risk_axis() {
4327 let mut mixed = make_file_score("/src/mixed.ts", 88.0, 110.0);
4330 mixed.crap_above_threshold = 1;
4331 mixed.crap_exempted = 1;
4332 mixed.crap_effective_threshold = Some(30.0);
4333 assert!(!file_score_fully_crap_exempt(&mixed, CRAP_THRESHOLD));
4334 assert_eq!(
4335 file_score_concern_axis(&mixed, CRAP_THRESHOLD),
4336 FileScoreConcern::Risk
4337 );
4338 }
4339
4340 #[test]
4341 fn file_score_concern_axis_labels_dominant_signal() {
4342 let risk_driven = make_file_score("/src/risk.ts", 84.8, 552.0);
4343 assert_eq!(
4344 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD),
4345 FileScoreConcern::Risk
4346 );
4347 assert_eq!(
4348 file_score_concern_axis(&risk_driven, CRAP_THRESHOLD).label(),
4349 "risk"
4350 );
4351
4352 let structure_driven = make_file_score("/src/structure.ts", 30.0, 8.0);
4353 assert_eq!(
4354 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD),
4355 FileScoreConcern::Structural
4356 );
4357 assert_eq!(
4358 file_score_concern_axis(&structure_driven, CRAP_THRESHOLD).label(),
4359 "structure"
4360 );
4361
4362 let no_risk = make_file_score("/src/clean.ts", 100.0, 0.0);
4363 assert_eq!(
4364 file_score_concern_axis(&no_risk, CRAP_THRESHOLD),
4365 FileScoreConcern::Structural
4366 );
4367 }
4368
4369 #[test]
4370 fn file_score_triage_sort_prioritizes_high_crap_over_slightly_lower_mi() {
4371 let low_mi_low_risk = make_file_score("/src/low-mi-low-risk.ts", 81.7, 2.0);
4372 let higher_mi_high_risk = make_file_score("/src/higher-mi-high-risk.ts", 84.8, 552.0);
4373
4374 let mut scores = [low_mi_low_risk, higher_mi_high_risk];
4375 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4376
4377 assert_eq!(
4378 scores[0].path,
4379 std::path::Path::new("/src/higher-mi-high-risk.ts")
4380 );
4381 assert_eq!(
4382 scores[1].path,
4383 std::path::Path::new("/src/low-mi-low-risk.ts")
4384 );
4385 }
4386
4387 #[test]
4388 fn file_score_triage_sort_orders_saturated_crap_by_raw_crap_descending() {
4389 let lower_crap_worse_mi = make_file_score("/src/a.ts", 84.8, 106.0);
4390 let higher_crap_better_mi = make_file_score("/src/b.ts", 96.7, 552.0);
4391
4392 let mut scores = [lower_crap_worse_mi, higher_crap_better_mi];
4393 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4394
4395 assert_eq!(scores[0].path, std::path::Path::new("/src/b.ts"));
4396 assert_eq!(scores[1].path, std::path::Path::new("/src/a.ts"));
4397 }
4398
4399 #[test]
4400 fn file_score_triage_sort_uses_mi_crap_and_path_tie_breakers() {
4401 let mut scores = [
4402 make_file_score("/src/b.ts", 70.0, 1.0),
4403 make_file_score("/src/a.ts", 70.0, 1.0),
4404 make_file_score("/src/higher-crap.ts", 70.0, 2.0),
4405 make_file_score("/src/lower-concern.ts", 80.0, 1.0),
4406 ];
4407
4408 scores.sort_by(|a, b| compare_file_score_triage(a, b, CRAP_THRESHOLD));
4409
4410 let paths: Vec<_> = scores.iter().map(|score| score.path.as_path()).collect();
4411 assert_eq!(
4412 paths,
4413 vec![
4414 std::path::Path::new("/src/higher-crap.ts"),
4415 std::path::Path::new("/src/a.ts"),
4416 std::path::Path::new("/src/b.ts"),
4417 std::path::Path::new("/src/lower-concern.ts"),
4418 ]
4419 );
4420 }
4421
4422 #[test]
4423 fn compute_file_scores_empty_graph() {
4424 let files: Vec<crate::discover::DiscoveredFile> = vec![];
4425 let graph = build_test_graph(&files, &[], &[]);
4426 let modules: Vec<crate::source::ModuleInfo> = vec![];
4427 let file_paths = rustc_hash::FxHashMap::default();
4428
4429 let output = crate::results::DeadCodeAnalysisArtifacts {
4430 results: fallow_types::results::AnalysisResults::default(),
4431 timings: None,
4432 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4433 modules: None,
4434 files: None,
4435 script_used_packages: rustc_hash::FxHashSet::default(),
4436 trace_provenance: crate::trace::TraceProvenance::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!(result.scores.is_empty());
4450 assert!(result.circular_files.is_empty());
4451 assert!(result.top_complex_fns.is_empty());
4452 assert!(result.entry_points.is_empty());
4453 assert_eq!(result.analysis_counts.total_exports, 0);
4454 assert_eq!(result.analysis_counts.dead_files, 0);
4455 }
4456
4457 #[test]
4458 fn compute_file_scores_no_graph_returns_error() {
4459 let modules: Vec<crate::source::ModuleInfo> = vec![];
4460 let file_paths = rustc_hash::FxHashMap::default();
4461
4462 let output = crate::results::DeadCodeAnalysisArtifacts {
4463 results: fallow_types::results::AnalysisResults::default(),
4464 timings: None,
4465 graph: None,
4466 modules: None,
4467 files: None,
4468 script_used_packages: rustc_hash::FxHashSet::default(),
4469 trace_provenance: crate::trace::TraceProvenance::default(),
4470 file_hashes: rustc_hash::FxHashMap::default(),
4471 };
4472
4473 let result = compute_file_scores_default(
4474 &modules,
4475 &file_paths,
4476 None,
4477 output,
4478 None,
4479 std::path::Path::new("/project"),
4480 );
4481 assert!(result.is_err());
4482 match result {
4483 Err(msg) => assert_eq!(msg, "graph not available"),
4484 Ok(_) => panic!("expected error"),
4485 }
4486 }
4487
4488 #[test]
4489 fn compute_file_scores_single_file_with_function() {
4490 let path_a = std::path::PathBuf::from("/src/a.ts");
4491 let files = vec![crate::discover::DiscoveredFile {
4492 id: crate::discover::FileId(0),
4493 path: path_a.clone(),
4494 size_bytes: 100,
4495 }];
4496
4497 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4498 file_id: crate::discover::FileId(0),
4499 path: path_a.clone(),
4500 exports: vec![fallow_types::extract::ExportInfo {
4501 name: crate::source::ExportName::Named("foo".into()),
4502 local_name: None,
4503 is_type_only: false,
4504 visibility: crate::source::VisibilityTag::None,
4505 expected_unused_reason: None,
4506 span: oxc_span::Span::empty(0),
4507 members: vec![],
4508 is_side_effect_used: false,
4509 super_class: None,
4510 deprecated: false,
4511 deprecated_reason: None,
4512 }]
4513 .into(),
4514 ..Default::default()
4515 }];
4516
4517 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4518
4519 let modules = vec![make_module_info(
4520 0,
4521 10,
4522 vec![fallow_types::extract::FunctionComplexity {
4523 name: "foo".into(),
4524 is_private_member: false,
4525 context_named: None,
4526 line: 1,
4527 col: 0,
4528 cyclomatic: 5,
4529 cognitive: 3,
4530 line_count: 10,
4531 param_count: 0,
4532 react_hook_count: 0,
4533 react_jsx_max_depth: 0,
4534 react_prop_count: 0,
4535 source_hash: None,
4536 contributions: Vec::new(),
4537 }],
4538 )];
4539
4540 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4541 rustc_hash::FxHashMap::default();
4542 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4543
4544 let output = crate::results::DeadCodeAnalysisArtifacts {
4545 results: fallow_types::results::AnalysisResults::default(),
4546 timings: None,
4547 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4548 modules: None,
4549 files: None,
4550 script_used_packages: rustc_hash::FxHashSet::default(),
4551 trace_provenance: crate::trace::TraceProvenance::default(),
4552 file_hashes: rustc_hash::FxHashMap::default(),
4553 };
4554
4555 let result = compute_file_scores_default(
4556 &modules,
4557 &file_paths,
4558 None,
4559 output,
4560 None,
4561 std::path::Path::new("/project"),
4562 )
4563 .unwrap();
4564 assert_eq!(result.scores.len(), 1);
4565
4566 let score = &result.scores[0];
4567 assert_eq!(score.path, path_a);
4568 assert_eq!(score.total_cyclomatic, 5);
4569 assert_eq!(score.total_cognitive, 3);
4570 assert_eq!(score.function_count, 1);
4571 assert_eq!(score.lines, 10);
4572 assert!((score.complexity_density - 0.5).abs() < f64::EPSILON);
4573 assert!(score.dead_code_ratio.abs() < f64::EPSILON);
4574 assert!(result.entry_points.contains(&path_a));
4575 }
4576
4577 #[test]
4578 fn compute_file_scores_excludes_barrel_files() {
4579 let path_a = std::path::PathBuf::from("/src/index.ts");
4580 let files = vec![crate::discover::DiscoveredFile {
4581 id: crate::discover::FileId(0),
4582 path: path_a.clone(),
4583 size_bytes: 50,
4584 }];
4585
4586 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4587 file_id: crate::discover::FileId(0),
4588 path: path_a.clone(),
4589 ..Default::default()
4590 }];
4591
4592 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
4593
4594 let modules = vec![make_module_info(0, 5, vec![])];
4595
4596 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4597 rustc_hash::FxHashMap::default();
4598 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4599
4600 let output = crate::results::DeadCodeAnalysisArtifacts {
4601 results: fallow_types::results::AnalysisResults::default(),
4602 timings: None,
4603 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4604 modules: None,
4605 files: None,
4606 script_used_packages: rustc_hash::FxHashSet::default(),
4607 trace_provenance: crate::trace::TraceProvenance::default(),
4608 file_hashes: rustc_hash::FxHashMap::default(),
4609 };
4610
4611 let result = compute_file_scores_default(
4612 &modules,
4613 &file_paths,
4614 None,
4615 output,
4616 None,
4617 std::path::Path::new("/project"),
4618 )
4619 .unwrap();
4620 assert!(result.scores.is_empty());
4621 }
4622
4623 #[test]
4624 fn compute_file_scores_changed_since_filter() {
4625 let path_a = std::path::PathBuf::from("/src/a.ts");
4626 let path_b = std::path::PathBuf::from("/src/b.ts");
4627 let files = vec![
4628 crate::discover::DiscoveredFile {
4629 id: crate::discover::FileId(0),
4630 path: path_a.clone(),
4631 size_bytes: 100,
4632 },
4633 crate::discover::DiscoveredFile {
4634 id: crate::discover::FileId(1),
4635 path: path_b.clone(),
4636 size_bytes: 100,
4637 },
4638 ];
4639
4640 let resolved_modules = vec![
4641 fallow_graph::resolve::ResolvedModule {
4642 file_id: crate::discover::FileId(0),
4643 path: path_a,
4644 ..Default::default()
4645 },
4646 fallow_graph::resolve::ResolvedModule {
4647 file_id: crate::discover::FileId(1),
4648 path: path_b.clone(),
4649 ..Default::default()
4650 },
4651 ];
4652
4653 let graph = build_test_graph(&files, &[], &resolved_modules);
4654
4655 let modules = vec![
4656 make_module_info(
4657 0,
4658 10,
4659 vec![fallow_types::extract::FunctionComplexity {
4660 name: "fn_a".into(),
4661 is_private_member: false,
4662 context_named: None,
4663 line: 1,
4664 col: 0,
4665 cyclomatic: 2,
4666 cognitive: 1,
4667 line_count: 10,
4668 param_count: 0,
4669 react_hook_count: 0,
4670 react_jsx_max_depth: 0,
4671 react_prop_count: 0,
4672 source_hash: None,
4673 contributions: Vec::new(),
4674 }],
4675 ),
4676 make_module_info(
4677 1,
4678 10,
4679 vec![fallow_types::extract::FunctionComplexity {
4680 name: "fn_b".into(),
4681 is_private_member: false,
4682 context_named: None,
4683 line: 1,
4684 col: 0,
4685 cyclomatic: 3,
4686 cognitive: 2,
4687 line_count: 10,
4688 param_count: 0,
4689 react_hook_count: 0,
4690 react_jsx_max_depth: 0,
4691 react_prop_count: 0,
4692 source_hash: None,
4693 contributions: Vec::new(),
4694 }],
4695 ),
4696 ];
4697
4698 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4699 rustc_hash::FxHashMap::default();
4700 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4701 file_paths.insert(crate::discover::FileId(1), &files[1].path);
4702
4703 let path_b_check = std::path::PathBuf::from("/src/b.ts");
4704 let mut changed = rustc_hash::FxHashSet::default();
4705 changed.insert(path_b);
4706
4707 let output = crate::results::DeadCodeAnalysisArtifacts {
4708 results: fallow_types::results::AnalysisResults::default(),
4709 timings: None,
4710 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4711 modules: None,
4712 files: None,
4713 script_used_packages: rustc_hash::FxHashSet::default(),
4714 trace_provenance: crate::trace::TraceProvenance::default(),
4715 file_hashes: rustc_hash::FxHashMap::default(),
4716 };
4717
4718 let result = compute_file_scores_default(
4719 &modules,
4720 &file_paths,
4721 Some(&changed),
4722 output,
4723 None,
4724 std::path::Path::new("/project"),
4725 )
4726 .unwrap();
4727 assert_eq!(result.scores.len(), 1);
4728 assert_eq!(result.scores[0].path, path_b_check);
4729 }
4730
4731 #[test]
4732 fn compute_file_scores_sorted_by_triage_concern() {
4733 let path_a = std::path::PathBuf::from("/src/a.ts");
4734 let path_b = std::path::PathBuf::from("/src/b.ts");
4735 let files = vec![
4736 crate::discover::DiscoveredFile {
4737 id: crate::discover::FileId(0),
4738 path: path_a.clone(),
4739 size_bytes: 100,
4740 },
4741 crate::discover::DiscoveredFile {
4742 id: crate::discover::FileId(1),
4743 path: path_b.clone(),
4744 size_bytes: 100,
4745 },
4746 ];
4747
4748 let resolved_modules = vec![
4749 fallow_graph::resolve::ResolvedModule {
4750 file_id: crate::discover::FileId(0),
4751 path: path_a.clone(),
4752 ..Default::default()
4753 },
4754 fallow_graph::resolve::ResolvedModule {
4755 file_id: crate::discover::FileId(1),
4756 path: path_b,
4757 ..Default::default()
4758 },
4759 ];
4760
4761 let graph = build_test_graph(&files, &[], &resolved_modules);
4762
4763 let modules = vec![
4764 make_module_info(
4765 0,
4766 10,
4767 vec![fallow_types::extract::FunctionComplexity {
4768 name: "complex_fn".into(),
4769 is_private_member: false,
4770 context_named: None,
4771 line: 1,
4772 col: 0,
4773 cyclomatic: 30,
4774 cognitive: 20,
4775 line_count: 10,
4776 param_count: 0,
4777 react_hook_count: 0,
4778 react_jsx_max_depth: 0,
4779 react_prop_count: 0,
4780 source_hash: None,
4781 contributions: Vec::new(),
4782 }],
4783 ),
4784 make_module_info(
4785 1,
4786 100,
4787 vec![fallow_types::extract::FunctionComplexity {
4788 name: "simple_fn".into(),
4789 is_private_member: false,
4790 context_named: None,
4791 line: 1,
4792 col: 0,
4793 cyclomatic: 1,
4794 cognitive: 0,
4795 line_count: 100,
4796 param_count: 0,
4797 react_hook_count: 0,
4798 react_jsx_max_depth: 0,
4799 react_prop_count: 0,
4800 source_hash: None,
4801 contributions: Vec::new(),
4802 }],
4803 ),
4804 ];
4805
4806 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4807 rustc_hash::FxHashMap::default();
4808 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4809 file_paths.insert(crate::discover::FileId(1), &files[1].path);
4810
4811 let output = crate::results::DeadCodeAnalysisArtifacts {
4812 results: fallow_types::results::AnalysisResults::default(),
4813 timings: None,
4814 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4815 modules: None,
4816 files: None,
4817 script_used_packages: rustc_hash::FxHashSet::default(),
4818 trace_provenance: crate::trace::TraceProvenance::default(),
4819 file_hashes: rustc_hash::FxHashMap::default(),
4820 };
4821
4822 let result = compute_file_scores_default(
4823 &modules,
4824 &file_paths,
4825 None,
4826 output,
4827 None,
4828 std::path::Path::new("/project"),
4829 )
4830 .unwrap();
4831 assert_eq!(result.scores.len(), 2);
4832 assert!(result.scores[0].maintainability_index <= result.scores[1].maintainability_index);
4833 assert_eq!(result.scores[0].path, path_a);
4834 }
4835
4836 #[test]
4837 fn compute_file_scores_with_unused_file_populates_evidence() {
4838 let path_a = std::path::PathBuf::from("/src/unused.ts");
4839 let files = vec![crate::discover::DiscoveredFile {
4840 id: crate::discover::FileId(0),
4841 path: path_a.clone(),
4842 size_bytes: 100,
4843 }];
4844
4845 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4846 file_id: crate::discover::FileId(0),
4847 path: path_a.clone(),
4848 exports: vec![fallow_types::extract::ExportInfo {
4849 name: crate::source::ExportName::Named("orphan".into()),
4850 local_name: None,
4851 is_type_only: false,
4852 visibility: crate::source::VisibilityTag::None,
4853 expected_unused_reason: None,
4854 span: oxc_span::Span::empty(0),
4855 members: vec![],
4856 is_side_effect_used: false,
4857 super_class: None,
4858 deprecated: false,
4859 deprecated_reason: None,
4860 }]
4861 .into(),
4862 ..Default::default()
4863 }];
4864
4865 let graph = build_test_graph(&files, &[], &resolved_modules);
4866
4867 let modules = vec![make_module_info(
4868 0,
4869 10,
4870 vec![fallow_types::extract::FunctionComplexity {
4871 name: "orphan".into(),
4872 is_private_member: false,
4873 context_named: None,
4874 line: 1,
4875 col: 0,
4876 cyclomatic: 1,
4877 cognitive: 0,
4878 line_count: 10,
4879 param_count: 0,
4880 react_hook_count: 0,
4881 react_jsx_max_depth: 0,
4882 react_prop_count: 0,
4883 source_hash: None,
4884 contributions: Vec::new(),
4885 }],
4886 )];
4887
4888 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
4889 rustc_hash::FxHashMap::default();
4890 file_paths.insert(crate::discover::FileId(0), &files[0].path);
4891
4892 let mut results = fallow_types::results::AnalysisResults::default();
4893 results.unused_files.push(
4894 fallow_types::output_dead_code::UnusedFileFinding::with_actions(
4895 fallow_types::results::UnusedFile {
4896 path: path_a.clone(),
4897 },
4898 ),
4899 );
4900
4901 let output = crate::results::DeadCodeAnalysisArtifacts {
4902 results,
4903 timings: None,
4904 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
4905 modules: None,
4906 files: None,
4907 script_used_packages: rustc_hash::FxHashSet::default(),
4908 trace_provenance: crate::trace::TraceProvenance::default(),
4909 file_hashes: rustc_hash::FxHashMap::default(),
4910 };
4911
4912 let result = compute_file_scores_default(
4913 &modules,
4914 &file_paths,
4915 None,
4916 output,
4917 None,
4918 std::path::Path::new("/project"),
4919 )
4920 .unwrap();
4921 assert_eq!(result.scores.len(), 1);
4922 assert!((result.scores[0].dead_code_ratio - 1.0).abs() < f64::EPSILON);
4923 assert!(result.unused_export_names.contains_key(&path_a));
4924 let names = &result.unused_export_names[&path_a];
4925 assert_eq!(names, &["orphan"]);
4926 assert_eq!(result.analysis_counts.dead_files, 1);
4927 }
4928
4929 #[test]
4930 #[expect(
4931 clippy::too_many_lines,
4932 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4933 )]
4934 fn compute_file_scores_tracks_top_complex_functions() {
4935 let path_a = std::path::PathBuf::from("/src/complex.ts");
4936 let files = vec![crate::discover::DiscoveredFile {
4937 id: crate::discover::FileId(0),
4938 path: path_a.clone(),
4939 size_bytes: 500,
4940 }];
4941
4942 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
4943 file_id: crate::discover::FileId(0),
4944 path: path_a.clone(),
4945 ..Default::default()
4946 }];
4947
4948 let graph = build_test_graph(&files, &[], &resolved_modules);
4949
4950 let modules = vec![make_module_info(
4951 0,
4952 50,
4953 vec![
4954 fallow_types::extract::FunctionComplexity {
4955 name: "high".into(),
4956 is_private_member: false,
4957 context_named: None,
4958 line: 1,
4959 col: 0,
4960 cyclomatic: 10,
4961 cognitive: 20,
4962 line_count: 10,
4963 param_count: 0,
4964 react_hook_count: 0,
4965 react_jsx_max_depth: 0,
4966 react_prop_count: 0,
4967 source_hash: None,
4968 contributions: Vec::new(),
4969 },
4970 fallow_types::extract::FunctionComplexity {
4971 name: "medium".into(),
4972 is_private_member: false,
4973 context_named: None,
4974 line: 11,
4975 col: 0,
4976 cyclomatic: 5,
4977 cognitive: 10,
4978 line_count: 10,
4979 param_count: 0,
4980 react_hook_count: 0,
4981 react_jsx_max_depth: 0,
4982 react_prop_count: 0,
4983 source_hash: None,
4984 contributions: Vec::new(),
4985 },
4986 fallow_types::extract::FunctionComplexity {
4987 name: "low".into(),
4988 is_private_member: false,
4989 context_named: None,
4990 line: 21,
4991 col: 0,
4992 cyclomatic: 2,
4993 cognitive: 5,
4994 line_count: 10,
4995 param_count: 0,
4996 react_hook_count: 0,
4997 react_jsx_max_depth: 0,
4998 react_prop_count: 0,
4999 source_hash: None,
5000 contributions: Vec::new(),
5001 },
5002 fallow_types::extract::FunctionComplexity {
5003 name: "trivial".into(),
5004 is_private_member: false,
5005 context_named: None,
5006 line: 31,
5007 col: 0,
5008 cyclomatic: 1,
5009 cognitive: 1,
5010 line_count: 10,
5011 param_count: 0,
5012 react_hook_count: 0,
5013 react_jsx_max_depth: 0,
5014 react_prop_count: 0,
5015 source_hash: None,
5016 contributions: Vec::new(),
5017 },
5018 ],
5019 )];
5020
5021 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5022 rustc_hash::FxHashMap::default();
5023 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5024
5025 let output = crate::results::DeadCodeAnalysisArtifacts {
5026 results: fallow_types::results::AnalysisResults::default(),
5027 timings: None,
5028 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5029 modules: None,
5030 files: None,
5031 script_used_packages: rustc_hash::FxHashSet::default(),
5032 trace_provenance: crate::trace::TraceProvenance::default(),
5033 file_hashes: rustc_hash::FxHashMap::default(),
5034 };
5035
5036 let result = compute_file_scores_default(
5037 &modules,
5038 &file_paths,
5039 None,
5040 output,
5041 None,
5042 std::path::Path::new("/project"),
5043 )
5044 .unwrap();
5045 assert!(result.top_complex_fns.contains_key(&path_a));
5046 let top = &result.top_complex_fns[&path_a];
5047 assert_eq!(top.len(), 3);
5048 assert_eq!(top[0].0, "high");
5049 assert_eq!(top[0].2, 20);
5050 assert_eq!(top[1].0, "medium");
5051 assert_eq!(top[1].2, 10);
5052 assert_eq!(top[2].0, "low");
5053 assert_eq!(top[2].2, 5);
5054 }
5055
5056 #[test]
5057 #[expect(
5058 clippy::too_many_lines,
5059 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5060 )]
5061 fn compute_file_scores_with_circular_deps() {
5062 let path_a = std::path::PathBuf::from("/src/a.ts");
5063 let path_b = std::path::PathBuf::from("/src/b.ts");
5064 let files = vec![
5065 crate::discover::DiscoveredFile {
5066 id: crate::discover::FileId(0),
5067 path: path_a.clone(),
5068 size_bytes: 100,
5069 },
5070 crate::discover::DiscoveredFile {
5071 id: crate::discover::FileId(1),
5072 path: path_b.clone(),
5073 size_bytes: 100,
5074 },
5075 ];
5076
5077 let resolved_modules = vec![
5078 fallow_graph::resolve::ResolvedModule {
5079 file_id: crate::discover::FileId(0),
5080 path: path_a.clone(),
5081 ..Default::default()
5082 },
5083 fallow_graph::resolve::ResolvedModule {
5084 file_id: crate::discover::FileId(1),
5085 path: path_b.clone(),
5086 ..Default::default()
5087 },
5088 ];
5089
5090 let graph = build_test_graph(&files, &[], &resolved_modules);
5091
5092 let modules = vec![
5093 make_module_info(
5094 0,
5095 10,
5096 vec![fallow_types::extract::FunctionComplexity {
5097 name: "fn_a".into(),
5098 is_private_member: false,
5099 context_named: None,
5100 line: 1,
5101 col: 0,
5102 cyclomatic: 2,
5103 cognitive: 1,
5104 line_count: 10,
5105 param_count: 0,
5106 react_hook_count: 0,
5107 react_jsx_max_depth: 0,
5108 react_prop_count: 0,
5109 source_hash: None,
5110 contributions: Vec::new(),
5111 }],
5112 ),
5113 make_module_info(
5114 1,
5115 10,
5116 vec![fallow_types::extract::FunctionComplexity {
5117 name: "fn_b".into(),
5118 is_private_member: false,
5119 context_named: None,
5120 line: 1,
5121 col: 0,
5122 cyclomatic: 3,
5123 cognitive: 2,
5124 line_count: 10,
5125 param_count: 0,
5126 react_hook_count: 0,
5127 react_jsx_max_depth: 0,
5128 react_prop_count: 0,
5129 source_hash: None,
5130 contributions: Vec::new(),
5131 }],
5132 ),
5133 ];
5134
5135 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5136 rustc_hash::FxHashMap::default();
5137 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5138 file_paths.insert(crate::discover::FileId(1), &files[1].path);
5139
5140 let mut results = fallow_types::results::AnalysisResults::default();
5141 results.circular_dependencies.push(
5142 fallow_types::output_dead_code::CircularDependencyFinding::with_actions(
5143 fallow_types::results::CircularDependency {
5144 files: vec![path_a.clone(), path_b.clone()],
5145 length: 2,
5146 line: 1,
5147 col: 0,
5148 edges: Vec::new(),
5149 is_cross_package: false,
5150 },
5151 ),
5152 );
5153
5154 let output = crate::results::DeadCodeAnalysisArtifacts {
5155 results,
5156 timings: None,
5157 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5158 modules: None,
5159 files: None,
5160 script_used_packages: rustc_hash::FxHashSet::default(),
5161 trace_provenance: crate::trace::TraceProvenance::default(),
5162 file_hashes: rustc_hash::FxHashMap::default(),
5163 };
5164
5165 let result = compute_file_scores_default(
5166 &modules,
5167 &file_paths,
5168 None,
5169 output,
5170 None,
5171 std::path::Path::new("/project"),
5172 )
5173 .unwrap();
5174 assert!(result.circular_files.contains(&path_a));
5175 assert!(result.circular_files.contains(&path_b));
5176 assert!(result.cycle_members.contains_key(&path_a));
5177 assert_eq!(result.cycle_members[&path_a], vec![path_b.clone()]);
5178 assert!(result.cycle_members.contains_key(&path_b));
5179 assert_eq!(result.cycle_members[&path_b], vec![path_a]);
5180 assert_eq!(result.analysis_counts.circular_deps, 1);
5181 }
5182
5183 #[test]
5184 #[expect(
5185 clippy::too_many_lines,
5186 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5187 )]
5188 fn compute_file_scores_analysis_counts_unused_exports_and_types() {
5189 let path_a = std::path::PathBuf::from("/src/a.ts");
5190 let files = vec![crate::discover::DiscoveredFile {
5191 id: crate::discover::FileId(0),
5192 path: path_a.clone(),
5193 size_bytes: 100,
5194 }];
5195
5196 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5197 file_id: crate::discover::FileId(0),
5198 path: path_a.clone(),
5199 exports: vec![
5200 fallow_types::extract::ExportInfo {
5201 name: crate::source::ExportName::Named("foo".into()),
5202 local_name: None,
5203 is_type_only: false,
5204 visibility: crate::source::VisibilityTag::None,
5205 expected_unused_reason: None,
5206 span: oxc_span::Span::empty(0),
5207 members: vec![],
5208 is_side_effect_used: false,
5209 super_class: None,
5210 deprecated: false,
5211 deprecated_reason: None,
5212 },
5213 fallow_types::extract::ExportInfo {
5214 name: crate::source::ExportName::Named("bar".into()),
5215 local_name: None,
5216 is_type_only: false,
5217 visibility: crate::source::VisibilityTag::None,
5218 expected_unused_reason: None,
5219 span: oxc_span::Span::empty(0),
5220 members: vec![],
5221 is_side_effect_used: false,
5222 super_class: None,
5223 deprecated: false,
5224 deprecated_reason: None,
5225 },
5226 ]
5227 .into(),
5228 ..Default::default()
5229 }];
5230
5231 let graph = build_test_graph(&files, &[], &resolved_modules);
5232
5233 let mut module = make_module_info(
5234 0,
5235 10,
5236 vec![fallow_types::extract::FunctionComplexity {
5237 name: "fn_a".into(),
5238 is_private_member: false,
5239 context_named: None,
5240 line: 1,
5241 col: 0,
5242 cyclomatic: 1,
5243 cognitive: 0,
5244 line_count: 10,
5245 param_count: 0,
5246 react_hook_count: 0,
5247 react_jsx_max_depth: 0,
5248 react_prop_count: 0,
5249 source_hash: None,
5250 contributions: Vec::new(),
5251 }],
5252 );
5253 module.exports = vec![
5254 fallow_types::extract::ExportInfo {
5255 name: crate::source::ExportName::Named("foo".into()),
5256 local_name: None,
5257 is_type_only: false,
5258 visibility: crate::source::VisibilityTag::None,
5259 expected_unused_reason: None,
5260 span: oxc_span::Span::empty(0),
5261 members: vec![],
5262 is_side_effect_used: false,
5263 super_class: None,
5264 deprecated: false,
5265 deprecated_reason: None,
5266 },
5267 fallow_types::extract::ExportInfo {
5268 name: crate::source::ExportName::Named("bar".into()),
5269 local_name: None,
5270 is_type_only: false,
5271 visibility: crate::source::VisibilityTag::None,
5272 expected_unused_reason: None,
5273 span: oxc_span::Span::empty(0),
5274 members: vec![],
5275 is_side_effect_used: false,
5276 super_class: None,
5277 deprecated: false,
5278 deprecated_reason: None,
5279 },
5280 ]
5281 .into();
5282 let modules = vec![module];
5283
5284 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5285 rustc_hash::FxHashMap::default();
5286 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5287
5288 let mut results = fallow_types::results::AnalysisResults::default();
5289 results.unused_exports.push(
5290 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5291 fallow_types::results::UnusedExport {
5292 path: path_a.clone(),
5293 export_name: "foo".into(),
5294 is_type_only: false,
5295 line: 1,
5296 col: 0,
5297 span_start: 0,
5298 is_re_export: false,
5299 deprecated: false,
5300 deprecated_reason: None,
5301 },
5302 ),
5303 );
5304 results.unused_types.push(
5305 fallow_types::output_dead_code::UnusedTypeFinding::with_actions(
5306 fallow_types::results::UnusedExport {
5307 path: path_a,
5308 export_name: "MyType".into(),
5309 is_type_only: true,
5310 line: 5,
5311 col: 0,
5312 span_start: 40,
5313 is_re_export: false,
5314 deprecated: false,
5315 deprecated_reason: None,
5316 },
5317 ),
5318 );
5319 results.unused_dependencies.push(
5320 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
5321 fallow_types::results::UnusedDependency {
5322 package_name: "lodash".into(),
5323 location: fallow_types::results::DependencyLocation::Dependencies,
5324 path: std::path::PathBuf::from("/package.json"),
5325 line: 1,
5326 used_in_workspaces: Vec::new(),
5327 declared_and_imported_in: Vec::new(),
5328 },
5329 ),
5330 );
5331
5332 let output = crate::results::DeadCodeAnalysisArtifacts {
5333 results,
5334 timings: None,
5335 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5336 modules: None,
5337 files: None,
5338 script_used_packages: rustc_hash::FxHashSet::default(),
5339 trace_provenance: crate::trace::TraceProvenance::default(),
5340 file_hashes: rustc_hash::FxHashMap::default(),
5341 };
5342
5343 let result = compute_file_scores_default(
5344 &modules,
5345 &file_paths,
5346 None,
5347 output,
5348 None,
5349 std::path::Path::new("/project"),
5350 )
5351 .unwrap();
5352 assert_eq!(result.analysis_counts.total_exports, 2);
5353 assert_eq!(result.analysis_counts.dead_exports, 2);
5354 assert_eq!(result.analysis_counts.unused_deps, 1);
5355 }
5356
5357 #[test]
5359 #[expect(
5360 clippy::too_many_lines,
5361 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
5362 )]
5363 fn total_exports_counts_graph_modules_not_extraction_modules() {
5364 let path_a = std::path::PathBuf::from("/src/a.ts");
5365 let files = vec![crate::discover::DiscoveredFile {
5366 id: crate::discover::FileId(0),
5367 path: path_a.clone(),
5368 size_bytes: 100,
5369 }];
5370
5371 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5372 file_id: crate::discover::FileId(0),
5373 path: path_a.clone(),
5374 exports: vec![
5375 fallow_types::extract::ExportInfo {
5376 name: crate::source::ExportName::Named("foo".into()),
5377 local_name: None,
5378 is_type_only: false,
5379 visibility: crate::source::VisibilityTag::None,
5380 expected_unused_reason: None,
5381 span: oxc_span::Span::empty(0),
5382 members: vec![],
5383 is_side_effect_used: false,
5384 super_class: None,
5385 deprecated: false,
5386 deprecated_reason: None,
5387 },
5388 fallow_types::extract::ExportInfo {
5389 name: crate::source::ExportName::Named("bar".into()),
5390 local_name: None,
5391 is_type_only: false,
5392 visibility: crate::source::VisibilityTag::None,
5393 expected_unused_reason: None,
5394 span: oxc_span::Span::empty(0),
5395 members: vec![],
5396 is_side_effect_used: false,
5397 super_class: None,
5398 deprecated: false,
5399 deprecated_reason: None,
5400 },
5401 fallow_types::extract::ExportInfo {
5402 name: crate::source::ExportName::Named("baz".into()),
5403 local_name: None,
5404 is_type_only: false,
5405 visibility: crate::source::VisibilityTag::None,
5406 expected_unused_reason: None,
5407 span: oxc_span::Span::new(0, 0),
5408 members: vec![],
5409 is_side_effect_used: false,
5410 super_class: None,
5411 deprecated: false,
5412 deprecated_reason: None,
5413 },
5414 ]
5415 .into(),
5416 ..Default::default()
5417 }];
5418
5419 let graph = build_test_graph(&files, &[], &resolved_modules);
5420
5421 let mut module = make_module_info(
5422 0,
5423 10,
5424 vec![fallow_types::extract::FunctionComplexity {
5425 name: "fn_a".into(),
5426 is_private_member: false,
5427 context_named: None,
5428 line: 1,
5429 col: 0,
5430 cyclomatic: 1,
5431 cognitive: 0,
5432 line_count: 10,
5433 param_count: 0,
5434 react_hook_count: 0,
5435 react_jsx_max_depth: 0,
5436 react_prop_count: 0,
5437 source_hash: None,
5438 contributions: Vec::new(),
5439 }],
5440 );
5441 module.exports = vec![
5442 fallow_types::extract::ExportInfo {
5443 name: crate::source::ExportName::Named("foo".into()),
5444 local_name: None,
5445 is_type_only: false,
5446 visibility: crate::source::VisibilityTag::None,
5447 expected_unused_reason: None,
5448 span: oxc_span::Span::empty(0),
5449 members: vec![],
5450 is_side_effect_used: false,
5451 super_class: None,
5452 deprecated: false,
5453 deprecated_reason: None,
5454 },
5455 fallow_types::extract::ExportInfo {
5456 name: crate::source::ExportName::Named("bar".into()),
5457 local_name: None,
5458 is_type_only: false,
5459 visibility: crate::source::VisibilityTag::None,
5460 expected_unused_reason: None,
5461 span: oxc_span::Span::empty(0),
5462 members: vec![],
5463 is_side_effect_used: false,
5464 super_class: None,
5465 deprecated: false,
5466 deprecated_reason: None,
5467 },
5468 ]
5469 .into();
5470 let modules = vec![module];
5471
5472 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5473 rustc_hash::FxHashMap::default();
5474 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5475
5476 let mut results = fallow_types::results::AnalysisResults::default();
5477 for name in ["foo", "bar", "baz"] {
5478 results.unused_exports.push(
5479 fallow_types::output_dead_code::UnusedExportFinding::with_actions(
5480 fallow_types::results::UnusedExport {
5481 path: path_a.clone(),
5482 export_name: name.into(),
5483 is_type_only: false,
5484 line: 1,
5485 col: 0,
5486 span_start: 0,
5487 is_re_export: name == "baz",
5488 deprecated: false,
5489 deprecated_reason: None,
5490 },
5491 ),
5492 );
5493 }
5494
5495 let output = crate::results::DeadCodeAnalysisArtifacts {
5496 results,
5497 timings: None,
5498 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5499 modules: None,
5500 files: None,
5501 script_used_packages: rustc_hash::FxHashSet::default(),
5502 trace_provenance: crate::trace::TraceProvenance::default(),
5503 file_hashes: rustc_hash::FxHashMap::default(),
5504 };
5505
5506 let result = compute_file_scores_default(
5507 &modules,
5508 &file_paths,
5509 None,
5510 output,
5511 None,
5512 std::path::Path::new("/project"),
5513 )
5514 .unwrap();
5515 assert_eq!(result.analysis_counts.total_exports, 3);
5516 assert_eq!(result.analysis_counts.dead_exports, 3);
5517 }
5518
5519 #[test]
5520 fn compute_file_scores_module_not_in_file_paths_skipped() {
5521 let path_a = std::path::PathBuf::from("/src/a.ts");
5522 let files = vec![crate::discover::DiscoveredFile {
5523 id: crate::discover::FileId(0),
5524 path: path_a.clone(),
5525 size_bytes: 100,
5526 }];
5527
5528 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5529 file_id: crate::discover::FileId(0),
5530 path: path_a,
5531 ..Default::default()
5532 }];
5533
5534 let graph = build_test_graph(&files, &[], &resolved_modules);
5535
5536 let modules = vec![make_module_info(
5537 0,
5538 10,
5539 vec![fallow_types::extract::FunctionComplexity {
5540 name: "fn_a".into(),
5541 is_private_member: false,
5542 context_named: None,
5543 line: 1,
5544 col: 0,
5545 cyclomatic: 2,
5546 cognitive: 1,
5547 line_count: 10,
5548 param_count: 0,
5549 react_hook_count: 0,
5550 react_jsx_max_depth: 0,
5551 react_prop_count: 0,
5552 source_hash: None,
5553 contributions: Vec::new(),
5554 }],
5555 )];
5556
5557 let file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5558 rustc_hash::FxHashMap::default();
5559
5560 let output = crate::results::DeadCodeAnalysisArtifacts {
5561 results: fallow_types::results::AnalysisResults::default(),
5562 timings: None,
5563 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5564 modules: None,
5565 files: None,
5566 script_used_packages: rustc_hash::FxHashSet::default(),
5567 trace_provenance: crate::trace::TraceProvenance::default(),
5568 file_hashes: rustc_hash::FxHashMap::default(),
5569 };
5570
5571 let result = compute_file_scores_default(
5572 &modules,
5573 &file_paths,
5574 None,
5575 output,
5576 None,
5577 std::path::Path::new("/project"),
5578 )
5579 .unwrap();
5580 assert!(result.scores.is_empty());
5581 }
5582
5583 #[test]
5584 fn compute_file_scores_mi_rounded_to_one_decimal() {
5585 let path_a = std::path::PathBuf::from("/src/a.ts");
5586 let files = vec![crate::discover::DiscoveredFile {
5587 id: crate::discover::FileId(0),
5588 path: path_a.clone(),
5589 size_bytes: 100,
5590 }];
5591
5592 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5593 file_id: crate::discover::FileId(0),
5594 path: path_a.clone(),
5595 ..Default::default()
5596 }];
5597
5598 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
5599
5600 let modules = vec![make_module_info(
5601 0,
5602 100,
5603 vec![fallow_types::extract::FunctionComplexity {
5604 name: "fn".into(),
5605 is_private_member: false,
5606 context_named: None,
5607 line: 1,
5608 col: 0,
5609 cyclomatic: 7,
5610 cognitive: 3,
5611 line_count: 100,
5612 param_count: 0,
5613 react_hook_count: 0,
5614 react_jsx_max_depth: 0,
5615 react_prop_count: 0,
5616 source_hash: None,
5617 contributions: Vec::new(),
5618 }],
5619 )];
5620
5621 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5622 rustc_hash::FxHashMap::default();
5623 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5624
5625 let output = crate::results::DeadCodeAnalysisArtifacts {
5626 results: fallow_types::results::AnalysisResults::default(),
5627 timings: None,
5628 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5629 modules: None,
5630 files: None,
5631 script_used_packages: rustc_hash::FxHashSet::default(),
5632 trace_provenance: crate::trace::TraceProvenance::default(),
5633 file_hashes: rustc_hash::FxHashMap::default(),
5634 };
5635
5636 let result = compute_file_scores_default(
5637 &modules,
5638 &file_paths,
5639 None,
5640 output,
5641 None,
5642 std::path::Path::new("/project"),
5643 )
5644 .unwrap();
5645 let mi = result.scores[0].maintainability_index;
5646 let rounded = (mi * 10.0).round() / 10.0;
5647 assert!((mi - rounded).abs() < f64::EPSILON);
5648 }
5649
5650 #[test]
5651 fn compute_file_scores_value_export_counts_tracked() {
5652 let path_a = std::path::PathBuf::from("/src/a.ts");
5653 let files = vec![crate::discover::DiscoveredFile {
5654 id: crate::discover::FileId(0),
5655 path: path_a.clone(),
5656 size_bytes: 100,
5657 }];
5658
5659 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5660 file_id: crate::discover::FileId(0),
5661 path: path_a.clone(),
5662 exports: vec![
5663 fallow_types::extract::ExportInfo {
5664 name: crate::source::ExportName::Named("a".into()),
5665 local_name: None,
5666 is_type_only: false,
5667 visibility: crate::source::VisibilityTag::None,
5668 expected_unused_reason: None,
5669 span: oxc_span::Span::empty(0),
5670 members: vec![],
5671 is_side_effect_used: false,
5672 super_class: None,
5673 deprecated: false,
5674 deprecated_reason: None,
5675 },
5676 fallow_types::extract::ExportInfo {
5677 name: crate::source::ExportName::Named("b".into()),
5678 local_name: None,
5679 is_type_only: false,
5680 visibility: crate::source::VisibilityTag::None,
5681 expected_unused_reason: None,
5682 span: oxc_span::Span::empty(0),
5683 members: vec![],
5684 is_side_effect_used: false,
5685 super_class: None,
5686 deprecated: false,
5687 deprecated_reason: None,
5688 },
5689 fallow_types::extract::ExportInfo {
5690 name: crate::source::ExportName::Named("T".into()),
5691 local_name: None,
5692 is_type_only: true,
5693 visibility: crate::source::VisibilityTag::None,
5694 expected_unused_reason: None,
5695 span: oxc_span::Span::empty(0),
5696 members: vec![],
5697 is_side_effect_used: false,
5698 super_class: None,
5699 deprecated: false,
5700 deprecated_reason: None,
5701 },
5702 ]
5703 .into(),
5704 ..Default::default()
5705 }];
5706
5707 let graph = build_test_graph(&files, &[], &resolved_modules);
5708
5709 let modules = vec![make_module_info(
5710 0,
5711 10,
5712 vec![fallow_types::extract::FunctionComplexity {
5713 name: "fn_a".into(),
5714 is_private_member: false,
5715 context_named: None,
5716 line: 1,
5717 col: 0,
5718 cyclomatic: 2,
5719 cognitive: 1,
5720 line_count: 10,
5721 param_count: 0,
5722 react_hook_count: 0,
5723 react_jsx_max_depth: 0,
5724 react_prop_count: 0,
5725 source_hash: None,
5726 contributions: Vec::new(),
5727 }],
5728 )];
5729
5730 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5731 rustc_hash::FxHashMap::default();
5732 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5733
5734 let output = crate::results::DeadCodeAnalysisArtifacts {
5735 results: fallow_types::results::AnalysisResults::default(),
5736 timings: None,
5737 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5738 modules: None,
5739 files: None,
5740 script_used_packages: rustc_hash::FxHashSet::default(),
5741 trace_provenance: crate::trace::TraceProvenance::default(),
5742 file_hashes: rustc_hash::FxHashMap::default(),
5743 };
5744
5745 let result = compute_file_scores_default(
5746 &modules,
5747 &file_paths,
5748 None,
5749 output,
5750 None,
5751 std::path::Path::new("/project"),
5752 )
5753 .unwrap();
5754 assert_eq!(result.value_export_counts[&path_a], 2);
5755 }
5756
5757 #[test]
5758 fn compute_file_scores_top_complex_fns_zero_cognitive_excluded() {
5759 let path_a = std::path::PathBuf::from("/src/simple.ts");
5760 let files = vec![crate::discover::DiscoveredFile {
5761 id: crate::discover::FileId(0),
5762 path: path_a.clone(),
5763 size_bytes: 100,
5764 }];
5765
5766 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
5767 file_id: crate::discover::FileId(0),
5768 path: path_a.clone(),
5769 ..Default::default()
5770 }];
5771
5772 let graph = build_test_graph(&files, &[], &resolved_modules);
5773
5774 let modules = vec![make_module_info(
5775 0,
5776 10,
5777 vec![fallow_types::extract::FunctionComplexity {
5778 name: "trivial".into(),
5779 is_private_member: false,
5780 context_named: None,
5781 line: 1,
5782 col: 0,
5783 cyclomatic: 1,
5784 cognitive: 0,
5785 line_count: 10,
5786 param_count: 0,
5787 react_hook_count: 0,
5788 react_jsx_max_depth: 0,
5789 react_prop_count: 0,
5790 source_hash: None,
5791 contributions: Vec::new(),
5792 }],
5793 )];
5794
5795 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
5796 rustc_hash::FxHashMap::default();
5797 file_paths.insert(crate::discover::FileId(0), &files[0].path);
5798
5799 let output = crate::results::DeadCodeAnalysisArtifacts {
5800 results: fallow_types::results::AnalysisResults::default(),
5801 timings: None,
5802 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
5803 modules: None,
5804 files: None,
5805 script_used_packages: rustc_hash::FxHashSet::default(),
5806 trace_provenance: crate::trace::TraceProvenance::default(),
5807 file_hashes: rustc_hash::FxHashMap::default(),
5808 };
5809
5810 let result = compute_file_scores_default(
5811 &modules,
5812 &file_paths,
5813 None,
5814 output,
5815 None,
5816 std::path::Path::new("/project"),
5817 )
5818 .unwrap();
5819 assert!(!result.top_complex_fns.contains_key(&path_a));
5820 }
5821
5822 pub(super) fn make_fn_complexity(cyclomatic: u16) -> fallow_types::extract::FunctionComplexity {
5823 fallow_types::extract::FunctionComplexity {
5824 name: "test_fn".into(),
5825 is_private_member: false,
5826 context_named: None,
5827 line: 1,
5828 col: 0,
5829 cyclomatic,
5830 cognitive: 0,
5831 line_count: 10,
5832 param_count: 0,
5833 react_hook_count: 0,
5834 react_jsx_max_depth: 0,
5835 react_prop_count: 0,
5836 source_hash: None,
5837 contributions: Vec::new(),
5838 }
5839 }
5840
5841 fn make_named_fn_complexity(
5842 name: &str,
5843 line: u32,
5844 cyclomatic: u16,
5845 ) -> fallow_types::extract::FunctionComplexity {
5846 fallow_types::extract::FunctionComplexity {
5847 name: name.into(),
5848 is_private_member: false,
5849 context_named: None,
5850 line,
5851 col: 0,
5852 cyclomatic,
5853 cognitive: 0,
5854 line_count: 10,
5855 param_count: 0,
5856 react_hook_count: 0,
5857 react_jsx_max_depth: 0,
5858 react_prop_count: 0,
5859 source_hash: None,
5860 contributions: Vec::new(),
5861 }
5862 }
5863
5864 fn crap_override_entry(
5865 files: &[&str],
5866 functions: &[&str],
5867 max_crap: Option<f64>,
5868 ) -> fallow_config::HealthThresholdOverride {
5869 fallow_config::HealthThresholdOverride {
5870 files: files.iter().map(ToString::to_string).collect(),
5871 functions: functions.iter().map(ToString::to_string).collect(),
5872 max_cyclomatic: None,
5873 max_cognitive: None,
5874 max_crap,
5875 max_unit_size: None,
5876 reason: Some("test override".into()),
5877 }
5878 }
5879
5880 fn estimated_signals_with(
5881 resolver: &ThresholdOverrideResolver,
5882 relative: &str,
5883 enforce_crap: bool,
5884 complexity: &[fallow_types::extract::FunctionComplexity],
5885 ) -> CrapThresholdSignals {
5886 let ceilings = CrapCeilingLookup::new(
5887 CrapScoreThresholds {
5888 resolver,
5889 enforce_crap,
5890 },
5891 std::path::Path::new(relative),
5892 &[],
5893 );
5894 compute_crap_scores_estimated(
5895 complexity,
5896 &rustc_hash::FxHashSet::default(),
5897 false,
5898 fallow_output::CoverageSource::Estimated,
5899 &ceilings,
5900 )
5901 .signals
5902 }
5903
5904 #[test]
5905 fn crap_counting_exempts_functions_under_override_ceiling() {
5906 let resolver =
5909 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
5910 let fns = vec![
5911 make_named_fn_complexity("a", 1, 10),
5912 make_named_fn_complexity("b", 12, 10),
5913 ];
5914
5915 let covered = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5916 assert_eq!(covered.above, 0);
5917 assert_eq!(covered.exempted, 2);
5918 assert_eq!(covered.min_ceiling, Some(500.0));
5919
5920 let elsewhere = estimated_signals_with(&resolver, "src/other.ts", true, &fns);
5921 assert_eq!(elsewhere.above, 2);
5922 assert_eq!(elsewhere.exempted, 0);
5923 assert_eq!(elsewhere.min_ceiling, Some(CRAP_THRESHOLD));
5924 }
5925
5926 #[test]
5927 fn crap_counting_insufficient_override_keeps_count() {
5928 let resolver =
5929 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(50.0))]);
5930 let fns = vec![
5931 make_named_fn_complexity("a", 1, 10),
5932 make_named_fn_complexity("b", 12, 10),
5933 ];
5934
5935 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5936 assert_eq!(signals.above, 2);
5937 assert_eq!(signals.exempted, 0);
5938 assert_eq!(signals.min_ceiling, Some(50.0));
5939 }
5940
5941 #[test]
5942 fn crap_counting_partial_function_override() {
5943 let resolver =
5946 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &["a"], Some(500.0))]);
5947 let fns = vec![
5948 make_named_fn_complexity("a", 1, 10),
5949 make_named_fn_complexity("b", 12, 10),
5950 ];
5951
5952 let signals = estimated_signals_with(&resolver, "src/legacy.ts", true, &fns);
5953 assert_eq!(signals.above, 1);
5954 assert_eq!(signals.exempted, 1);
5955 assert_eq!(signals.min_ceiling, Some(CRAP_THRESHOLD));
5956 }
5957
5958 #[test]
5959 fn crap_counting_disabled_enforcement_counts_baseline_exemptions() {
5960 let resolver = test_crap_resolver(0.0);
5963 let fns = vec![
5964 make_named_fn_complexity("a", 1, 10),
5965 make_named_fn_complexity("b", 12, 10),
5966 make_named_fn_complexity("tiny", 24, 1),
5967 ];
5968
5969 let signals = estimated_signals_with(&resolver, "src/any.ts", false, &fns);
5970 assert_eq!(signals.above, 0);
5971 assert_eq!(signals.exempted, 2);
5972 }
5973
5974 #[test]
5975 fn crap_counting_stricter_ceiling_never_counts_exempt() {
5976 let resolver = test_crap_resolver(10.0);
5979 let fns = vec![make_named_fn_complexity("a", 1, 4)]; let signals = estimated_signals_with(&resolver, "src/any.ts", true, &fns);
5982 assert_eq!(signals.above, 1);
5983 assert_eq!(signals.exempted, 0);
5984 }
5985
5986 #[test]
5987 fn crap_counting_uses_rounded_value_at_boundary() {
5988 let funcs = vec![make_fn_complexity(10)];
5993 let mut functions = rustc_hash::FxHashMap::default();
5994 functions.insert(("test_fn".to_string(), 1, 0), 41.56);
5995 let file_cov = test_istanbul_file_coverage(functions, false);
5996
5997 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
5998 assert!((result.per_function[0].crap - 30.0).abs() < f64::EPSILON);
5999 assert_eq!(result.signals.above, 1);
6000 assert_eq!(result.signals.exempted, 0);
6001 }
6002
6003 #[test]
6004 fn compute_file_scores_discloses_override_exemption_on_row() {
6005 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
6006 let files = vec![crate::discover::DiscoveredFile {
6007 id: crate::discover::FileId(0),
6008 path: path_a.clone(),
6009 size_bytes: 100,
6010 }];
6011 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
6012 file_id: crate::discover::FileId(0),
6013 path: path_a.clone(),
6014 ..Default::default()
6015 }];
6016 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
6017 let modules = vec![make_module_info(
6018 0,
6019 26,
6020 vec![
6021 make_named_fn_complexity("a", 1, 10),
6022 make_named_fn_complexity("b", 12, 10),
6023 ],
6024 )];
6025 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
6026 rustc_hash::FxHashMap::default();
6027 file_paths.insert(crate::discover::FileId(0), &files[0].path);
6028 let output = crate::results::DeadCodeAnalysisArtifacts {
6029 results: fallow_types::results::AnalysisResults::default(),
6030 timings: None,
6031 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
6032 modules: None,
6033 files: None,
6034 script_used_packages: rustc_hash::FxHashSet::default(),
6035 trace_provenance: crate::trace::TraceProvenance::default(),
6036 file_hashes: rustc_hash::FxHashMap::default(),
6037 };
6038
6039 let resolver =
6040 test_override_resolver(&[crap_override_entry(&["src/legacy.ts"], &[], Some(500.0))]);
6041 let result = compute_file_scores(
6042 FileScoreComputeInput {
6043 modules: &modules,
6044 file_paths: &file_paths,
6045 changed_files: None,
6046 istanbul_coverage: None,
6047 root: std::path::Path::new("/project"),
6048 crap_thresholds: CrapScoreThresholds {
6049 resolver: &resolver,
6050 enforce_crap: true,
6051 },
6052 with_static_links: false,
6053 test_reach: None,
6054 },
6055 output,
6056 )
6057 .unwrap();
6058
6059 assert_eq!(result.scores.len(), 1);
6060 let score = &result.scores[0];
6061 assert!((score.crap_max - 110.0).abs() < f64::EPSILON);
6062 assert_eq!(score.crap_above_threshold, 0);
6063 assert_eq!(score.crap_exempted, 2);
6064 assert_eq!(score.crap_effective_threshold, Some(500.0));
6065 assert!(file_score_fully_crap_exempt(score, CRAP_THRESHOLD));
6066 assert_eq!(
6067 file_score_concern_axis(score, CRAP_THRESHOLD),
6068 FileScoreConcern::Structural
6069 );
6070 }
6071
6072 #[test]
6073 fn compute_file_scores_raised_global_omits_row_threshold() {
6074 let path_a = std::path::PathBuf::from("/project/src/legacy.ts");
6075 let files = vec![crate::discover::DiscoveredFile {
6076 id: crate::discover::FileId(0),
6077 path: path_a.clone(),
6078 size_bytes: 100,
6079 }];
6080 let resolved_modules = vec![fallow_graph::resolve::ResolvedModule {
6081 file_id: crate::discover::FileId(0),
6082 path: path_a.clone(),
6083 ..Default::default()
6084 }];
6085 let graph = build_test_graph(&files, std::slice::from_ref(&path_a), &resolved_modules);
6086 let modules = vec![make_module_info(
6087 0,
6088 26,
6089 vec![
6090 make_named_fn_complexity("a", 1, 10),
6091 make_named_fn_complexity("b", 12, 10),
6092 ],
6093 )];
6094 let mut file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf> =
6095 rustc_hash::FxHashMap::default();
6096 file_paths.insert(crate::discover::FileId(0), &files[0].path);
6097 let output = crate::results::DeadCodeAnalysisArtifacts {
6098 results: fallow_types::results::AnalysisResults::default(),
6099 timings: None,
6100 graph: Some(crate::module_graph::RetainedModuleGraph::from(graph)),
6101 modules: None,
6102 files: None,
6103 script_used_packages: rustc_hash::FxHashSet::default(),
6104 trace_provenance: crate::trace::TraceProvenance::default(),
6105 file_hashes: rustc_hash::FxHashMap::default(),
6106 };
6107
6108 let resolver = test_crap_resolver(5000.0);
6111 let result = compute_file_scores(
6112 FileScoreComputeInput {
6113 modules: &modules,
6114 file_paths: &file_paths,
6115 changed_files: None,
6116 istanbul_coverage: None,
6117 root: std::path::Path::new("/project"),
6118 crap_thresholds: CrapScoreThresholds {
6119 resolver: &resolver,
6120 enforce_crap: true,
6121 },
6122 with_static_links: false,
6123 test_reach: None,
6124 },
6125 output,
6126 )
6127 .unwrap();
6128
6129 assert_eq!(result.scores.len(), 1);
6130 let score = &result.scores[0];
6131 assert_eq!(score.crap_above_threshold, 0);
6132 assert_eq!(score.crap_exempted, 2);
6133 assert_eq!(score.crap_effective_threshold, None);
6134 assert!(file_score_fully_crap_exempt(score, 5000.0));
6135 assert_eq!(
6136 file_score_concern_axis(score, 5000.0),
6137 FileScoreConcern::Structural
6138 );
6139 }
6140
6141 #[test]
6146 fn estimated_crap_untested_aggregates_over_real_units_only() {
6147 let funcs = vec![
6148 make_named_fn_complexity("below", 1, 4),
6149 make_named_fn_complexity("at_threshold", 2, 5),
6150 make_named_fn_complexity("above_threshold", 3, 8),
6151 make_named_fn_complexity("<template>", 4, 21),
6152 make_named_fn_complexity("<module>", 5, 21),
6153 ];
6154 let result = estimated_crap_default(
6155 &funcs,
6156 &rustc_hash::FxHashSet::default(),
6157 false,
6158 fallow_output::CoverageSource::Estimated,
6159 );
6160 assert!((result.max_crap - 72.0).abs() < f64::EPSILON, "{result:#?}");
6161 assert_eq!(result.signals.above, 2);
6162 assert_eq!(result.per_function.len(), 3);
6163 }
6164
6165 #[test]
6166 fn crap_formula_full_coverage() {
6167 let result = crap_formula(10.0, 100.0);
6168 assert!((result - 10.0).abs() < f64::EPSILON);
6169 }
6170
6171 #[test]
6172 fn crap_formula_zero_coverage() {
6173 let result = crap_formula(5.0, 0.0);
6174 assert!((result - 30.0).abs() < f64::EPSILON);
6175 }
6176
6177 #[test]
6178 fn crap_formula_partial_coverage() {
6179 let result = crap_formula(10.0, 50.0);
6180 assert!((result - 22.5).abs() < f64::EPSILON);
6181 }
6182
6183 #[test]
6184 fn crap_formula_high_coverage_low_complexity() {
6185 let result = crap_formula(2.0, 90.0);
6186 assert!((result - 2.004).abs() < 0.001);
6187 }
6188
6189 #[test]
6194 fn crap_default_gate_cyclomatic_boundaries_per_estimate_tier() {
6195 for (coverage_pct, gate_cc) in [(0.0, 5.0), (40.0, 10.0), (85.0, 28.0)] {
6196 assert!(
6197 crap_formula(gate_cc, coverage_pct) >= CRAP_THRESHOLD,
6198 "cyclomatic {gate_cc} at {coverage_pct}% must reach the gate"
6199 );
6200 assert!(
6201 crap_formula(gate_cc - 1.0, coverage_pct) < CRAP_THRESHOLD,
6202 "cyclomatic {} at {coverage_pct}% must stay under the gate",
6203 gate_cc - 1.0
6204 );
6205 }
6206 }
6207
6208 #[test]
6209 fn istanbul_crap_excludes_synthetic_template_units() {
6210 let funcs = vec![
6211 make_named_fn_complexity("<template>", 1, 21),
6212 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
6213 make_fn_complexity(6),
6214 ];
6215 let result = istanbul_crap_default(&funcs, None, false);
6216 assert!((result.max_crap - 42.0).abs() < f64::EPSILON, "{result:#?}");
6217 assert_eq!(result.signals.above, 1);
6218 assert_eq!(
6219 result.total, 1,
6220 "template units must not count as unmatched"
6221 );
6222 assert_eq!(result.per_function.len(), 1);
6223 }
6224
6225 #[test]
6226 fn estimated_crap_excludes_synthetic_template_units() {
6227 let funcs = vec![
6228 make_named_fn_complexity("<template>", 1, 21),
6229 make_named_fn_complexity("<snippet:rowBody>", 1, 16),
6230 ];
6231 let result = estimated_crap_default(
6232 &funcs,
6233 &rustc_hash::FxHashSet::default(),
6234 false,
6235 fallow_output::CoverageSource::Estimated,
6236 );
6237 assert!(result.max_crap.abs() < f64::EPSILON, "{result:#?}");
6238 assert_eq!(result.signals.above, 0);
6239 assert!(result.per_function.is_empty());
6240 }
6241
6242 #[test]
6243 fn istanbul_crap_with_coverage_data() {
6244 let funcs = vec![make_fn_complexity(10)];
6245 let mut functions = rustc_hash::FxHashMap::default();
6246 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
6247 let file_cov = test_istanbul_file_coverage(functions, false);
6248 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6249 assert!((result.max_crap - 10.8).abs() < 0.1);
6250 assert_eq!(result.signals.above, 0);
6251 }
6252
6253 #[test]
6254 fn istanbul_crap_falls_back_to_binary_when_no_match() {
6255 let funcs = vec![make_fn_complexity(6)];
6256 let file_cov = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
6257 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6258 assert!((result.max_crap - 42.0).abs() < f64::EPSILON);
6259 assert_eq!(result.signals.above, 1);
6260 }
6261
6262 #[test]
6265 fn istanbul_crap_uses_the_static_estimate_when_no_file_coverage() {
6266 let funcs = vec![make_fn_complexity(5)];
6267 let result = istanbul_crap_default(&funcs, None, true);
6268 assert!((result.max_crap - 10.4).abs() < 1e-9);
6271 assert_eq!(result.signals.above, 0);
6272 }
6273
6274 #[test]
6275 fn istanbul_crap_zero_coverage_matches_binary_untested() {
6276 let funcs = vec![make_fn_complexity(5)];
6277 let mut functions = rustc_hash::FxHashMap::default();
6278 functions.insert(("test_fn".to_string(), 1, 0), 0.0);
6279 let file_cov = test_istanbul_file_coverage(functions, false);
6280 let result = istanbul_crap_default(&funcs, Some(&file_cov), false);
6281 assert!((result.max_crap - 30.0).abs() < f64::EPSILON);
6282 assert_eq!(result.signals.above, 1);
6283 }
6284
6285 #[test]
6286 fn estimated_crap_direct_test_reference() {
6287 let funcs = vec![make_fn_complexity(10)];
6288 let mut refs = rustc_hash::FxHashSet::default();
6289 refs.insert("test_fn".to_string());
6290 let result = estimated_crap_default(
6291 &funcs,
6292 &refs,
6293 true,
6294 fallow_output::CoverageSource::Estimated,
6295 );
6296 let (max, above) = (result.max_crap, result.signals.above);
6297 assert!((max - 10.3).abs() < 0.1);
6298 assert_eq!(above, 0);
6299 }
6300
6301 #[test]
6302 fn estimated_crap_context_name_is_not_a_direct_test_reference() {
6303 let mut generator = make_fn_complexity(10);
6304 generator.context_named = Some(fallow_types::extract::AnonymousFunctionKind::Function);
6305 let mut refs = rustc_hash::FxHashSet::default();
6306 refs.insert("test_fn".to_string());
6307 let with_context_name = estimated_crap_default(
6308 &[generator],
6309 &refs,
6310 true,
6311 fallow_output::CoverageSource::Estimated,
6312 );
6313 let without_reference = estimated_crap_default(
6314 &[make_fn_complexity(10)],
6315 &rustc_hash::FxHashSet::default(),
6316 true,
6317 fallow_output::CoverageSource::Estimated,
6318 );
6319 assert!((with_context_name.max_crap - without_reference.max_crap).abs() < f64::EPSILON);
6320 }
6321
6322 #[test]
6323 fn estimated_crap_indirect_test_reachable() {
6324 let funcs = vec![make_fn_complexity(10)];
6325 let refs = rustc_hash::FxHashSet::default();
6326 let result = estimated_crap_default(
6327 &funcs,
6328 &refs,
6329 true,
6330 fallow_output::CoverageSource::Estimated,
6331 );
6332 let (max, above) = (result.max_crap, result.signals.above);
6333 assert!((max - 31.6).abs() < 0.1);
6334 assert_eq!(above, 1);
6335 }
6336
6337 #[test]
6338 fn estimated_crap_untested_file() {
6339 let funcs = vec![make_fn_complexity(5)];
6340 let refs = rustc_hash::FxHashSet::default();
6341 let result = estimated_crap_default(
6342 &funcs,
6343 &refs,
6344 false,
6345 fallow_output::CoverageSource::Estimated,
6346 );
6347 let (max, above) = (result.max_crap, result.signals.above);
6348 assert!((max - 30.0).abs() < f64::EPSILON);
6349 assert_eq!(above, 1);
6350 }
6351
6352 #[test]
6353 fn estimated_crap_low_complexity_direct_ref() {
6354 let funcs = vec![make_fn_complexity(2)];
6355 let mut refs = rustc_hash::FxHashSet::default();
6356 refs.insert("test_fn".to_string());
6357 let result = estimated_crap_default(
6358 &funcs,
6359 &refs,
6360 true,
6361 fallow_output::CoverageSource::Estimated,
6362 );
6363 let (max, above) = (result.max_crap, result.signals.above);
6364 assert!(max < 3.0);
6365 assert_eq!(above, 0);
6366 }
6367
6368 #[test]
6369 fn estimated_crap_empty() {
6370 let refs = rustc_hash::FxHashSet::default();
6371 let result =
6372 estimated_crap_default(&[], &refs, true, fallow_output::CoverageSource::Estimated);
6373 let (max, above) = (result.max_crap, result.signals.above);
6374 assert!((max).abs() < f64::EPSILON);
6375 assert_eq!(above, 0);
6376 }
6377
6378 fn make_export(name: &str, is_type_only: bool) -> fallow_graph::graph::ExportSymbol {
6379 fallow_graph::graph::ExportSymbol {
6380 name: fallow_types::extract::ExportName::Named(name.into()),
6381 is_type_only,
6382 is_side_effect_used: false,
6383 visibility: crate::source::VisibilityTag::None,
6384 expected_unused_reason: None,
6385 span: oxc_span::Span::default(),
6386 references: vec![],
6387 reference_paths: Vec::new(),
6388 members: vec![],
6389 deprecated: false,
6390 deprecated_reason: None,
6391 }
6392 }
6393
6394 #[test]
6395 fn dead_code_ratio_type_only_exports_excluded_from_denominator() {
6396 let path = std::path::Path::new("src/types.ts");
6397 let exports = vec![
6398 make_export("MyInterface", true),
6399 make_export("MyType", true),
6400 make_export("myFunction", false),
6401 ];
6402 let unused_files = rustc_hash::FxHashSet::default();
6403 let mut unused_by_path = rustc_hash::FxHashMap::default();
6404 unused_by_path.insert(path, 1_usize); let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6407 assert!((ratio - 1.0).abs() < f64::EPSILON);
6408 }
6409
6410 #[test]
6411 fn dead_code_ratio_only_type_exports_returns_zero() {
6412 let path = std::path::Path::new("src/types.ts");
6413 let exports = vec![
6414 make_export("MyInterface", true),
6415 make_export("MyType", true),
6416 ];
6417 let unused_files = rustc_hash::FxHashSet::default();
6418 let unused_by_path = rustc_hash::FxHashMap::default();
6419
6420 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6421 assert!(ratio.abs() < f64::EPSILON);
6422 }
6423
6424 #[test]
6425 fn dead_code_ratio_mixed_exports_counts_only_values() {
6426 let path = std::path::Path::new("src/component.ts");
6427 let exports = vec![
6428 make_export("Props", true),
6429 make_export("State", true),
6430 make_export("Component", false),
6431 make_export("helper", false),
6432 ];
6433 let unused_files = rustc_hash::FxHashSet::default();
6434 let mut unused_by_path = rustc_hash::FxHashMap::default();
6435 unused_by_path.insert(path, 1_usize);
6436
6437 let ratio = compute_dead_code_ratio(path, &exports, &unused_files, &unused_by_path);
6438 assert!((ratio - 0.5).abs() < f64::EPSILON);
6439 }
6440
6441 pub(super) fn write_single_file_istanbul_fixture(
6442 coverage_path: &std::path::Path,
6443 source_path: &std::path::Path,
6444 fn_map: &serde_json::Value,
6445 function_hits: &serde_json::Value,
6446 ) {
6447 write_single_file_istanbul_fixture_with_statements(
6448 coverage_path,
6449 source_path,
6450 fn_map,
6451 function_hits,
6452 &serde_json::json!({}),
6453 &serde_json::json!({}),
6454 );
6455 }
6456
6457 fn write_single_file_istanbul_fixture_with_statements(
6458 coverage_path: &std::path::Path,
6459 source_path: &std::path::Path,
6460 fn_map: &serde_json::Value,
6461 function_hits: &serde_json::Value,
6462 statement_map: &serde_json::Value,
6463 statement_hits: &serde_json::Value,
6464 ) {
6465 let mut root = serde_json::Map::new();
6466 root.insert(
6467 source_path.to_string_lossy().into_owned(),
6468 serde_json::json!({
6469 "path": source_path.to_string_lossy().into_owned(),
6470 "statementMap": statement_map,
6471 "fnMap": fn_map,
6472 "branchMap": {},
6473 "s": statement_hits,
6474 "f": function_hits,
6475 "b": {}
6476 }),
6477 );
6478
6479 std::fs::write(coverage_path, serde_json::to_string(&root).unwrap()).unwrap();
6480 }
6481
6482 #[test]
6488 fn a_negative_branch_coordinate_does_not_cost_the_whole_map() {
6489 let temp = tempfile::TempDir::new().unwrap();
6490 let source_path = temp.path().join("src/pick.ts");
6491 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6492 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6493
6494 let coverage_path = temp.path().join("coverage-final.json");
6495 let source = source_path.to_string_lossy().into_owned();
6496 std::fs::write(
6497 &coverage_path,
6498 serde_json::to_string(&serde_json::json!({
6499 source.clone(): {
6500 "path": source,
6501 "statementMap": {},
6502 "fnMap": {
6503 "0": {
6504 "name": "pick",
6505 "line": 1,
6506 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 20 } },
6507 "loc": { "start": { "line": 1, "column": 41 }, "end": { "line": 6, "column": 1 } }
6508 }
6509 },
6510 "branchMap": {
6511 "0": {
6512 "type": "branch",
6513 "line": 5,
6514 "loc": {
6515 "start": { "line": 5, "column": -1 },
6516 "end": { "line": 6, "column": 0 }
6517 },
6518 "locations": [
6519 {
6520 "start": { "line": 5, "column": -1 },
6521 "end": { "line": 6, "column": 0 }
6522 }
6523 ]
6524 }
6525 },
6526 "s": {},
6527 "f": { "0": 2 },
6528 "b": { "0": [1, 0] }
6529 }
6530 }))
6531 .unwrap(),
6532 )
6533 .unwrap();
6534
6535 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6536 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6537 let file_coverage = coverage.get(&canonical_source).unwrap();
6538
6539 assert_eq!(file_coverage.lookup("pick", 1, 16), Some(100.0));
6540 }
6541
6542 #[test]
6547 fn a_negative_branch_count_does_not_cost_the_whole_map() {
6548 let temp = tempfile::TempDir::new().unwrap();
6549 let source_path = temp.path().join("src/a.js");
6550 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6551 std::fs::write(
6552 &source_path,
6553 "export function f(a) {\n if (a) {\n return 1;\n }\n return 2;\n}\n",
6554 )
6555 .unwrap();
6556
6557 let coverage_path = temp.path().join("coverage-final.json");
6558 let source = source_path.to_string_lossy().into_owned();
6559 let branch_loc = serde_json::json!({
6560 "start": { "line": 2, "column": 2 },
6561 "end": { "line": 4, "column": 3 }
6562 });
6563 std::fs::write(
6564 &coverage_path,
6565 serde_json::to_string(&serde_json::json!({
6566 source.clone(): {
6567 "path": source,
6568 "statementMap": {
6569 "0": branch_loc,
6570 "1": { "start": { "line": 3, "column": 4 }, "end": { "line": 3, "column": 13 } },
6571 "2": { "start": { "line": 5, "column": 2 }, "end": { "line": 5, "column": 11 } }
6572 },
6573 "fnMap": {
6574 "0": {
6575 "name": "f",
6576 "line": 1,
6577 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 17 } },
6578 "loc": { "start": { "line": 1, "column": 20 }, "end": { "line": 6, "column": 1 } }
6579 }
6580 },
6581 "branchMap": {
6582 "0": {
6583 "type": "if",
6584 "line": 2,
6585 "loc": branch_loc,
6586 "locations": [branch_loc, branch_loc]
6587 }
6588 },
6589 "s": { "0": 5, "1": 5, "2": 0 },
6590 "f": { "0": 5 },
6591 "b": { "0": [5, -1] },
6592 "b_T": { "0": [5, -5] }
6593 }
6594 }))
6595 .unwrap(),
6596 )
6597 .unwrap();
6598
6599 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6600 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6601 let file_coverage = coverage.get(&canonical_source).unwrap();
6602
6603 let pct = file_coverage.lookup("f", 1, 16).unwrap();
6604 assert!((pct - 200.0 / 3.0).abs() < 1e-9, "got {pct}");
6605 }
6606
6607 #[test]
6610 fn a_negative_statement_count_reads_as_a_miss() {
6611 let temp = tempfile::TempDir::new().unwrap();
6612 let source_path = temp.path().join("src/a.js");
6613 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6614 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6615
6616 let coverage_path = temp.path().join("coverage-final.json");
6617 write_single_file_istanbul_fixture_with_statements(
6618 &coverage_path,
6619 &source_path,
6620 &serde_json::json!({
6621 "0": {
6622 "name": "f",
6623 "line": 1,
6624 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 17 } },
6625 "loc": { "start": { "line": 1, "column": 20 }, "end": { "line": 6, "column": 1 } }
6626 }
6627 }),
6628 &serde_json::json!({ "0": -2 }),
6629 &serde_json::json!({
6630 "0": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 11 } },
6631 "1": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 11 } }
6632 }),
6633 &serde_json::json!({ "0": 3, "1": -1 }),
6634 );
6635
6636 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6637 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6638 let file_coverage = coverage.get(&canonical_source).unwrap();
6639
6640 assert_eq!(file_coverage.lookup("f", 1, 16), Some(50.0));
6641 }
6642
6643 #[test]
6647 fn an_accessor_answers_to_its_property_name() {
6648 let temp = tempfile::TempDir::new().unwrap();
6649 let source_path = temp.path().join("src/box.ts");
6650 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6651 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
6652
6653 let coverage_path = temp.path().join("coverage-final.json");
6654 write_single_file_istanbul_fixture(
6655 &coverage_path,
6656 &source_path,
6657 &serde_json::json!({
6658 "0": {
6659 "name": "get area",
6660 "line": 4,
6661 "decl": { "start": { "line": 4, "column": 6 }, "end": { "line": 4, "column": 10 } },
6662 "loc": { "start": { "line": 4, "column": 13 }, "end": { "line": 6, "column": 3 } }
6663 }
6664 }),
6665 &serde_json::json!({ "0": 0 }),
6666 );
6667
6668 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6669 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6670 let file_coverage = coverage.get(&canonical_source).unwrap();
6671
6672 assert_eq!(file_coverage.lookup("area", 4, 6), Some(0.0));
6674 assert_eq!(file_coverage.lookup("get area", 4, 6), Some(0.0));
6676 }
6677
6678 #[test]
6679 fn resolve_relative_to_root_joins_relative_with_project_root() {
6680 let resolved = resolve_relative_to_root(
6681 std::path::Path::new("coverage/coverage-final.json"),
6682 Some(std::path::Path::new("/work/my-app")),
6683 );
6684 assert_eq!(
6685 resolved,
6686 std::path::PathBuf::from("/work/my-app/coverage/coverage-final.json")
6687 );
6688 }
6689
6690 #[test]
6691 fn resolve_relative_to_root_returns_absolute_unchanged() {
6692 let resolved = resolve_relative_to_root(
6693 std::path::Path::new("/tmp/coverage-final.json"),
6694 Some(std::path::Path::new("/work/my-app")),
6695 );
6696 assert_eq!(
6697 resolved,
6698 std::path::PathBuf::from("/tmp/coverage-final.json")
6699 );
6700 }
6701
6702 #[test]
6703 fn resolve_relative_to_root_returns_windows_absolute_unchanged_on_any_host() {
6704 let path = std::path::Path::new(r"C:\coverage\coverage-final.json");
6705 let resolved = resolve_relative_to_root(path, Some(std::path::Path::new("/work/my-app")));
6706 assert_eq!(resolved, path);
6707 }
6708
6709 #[cfg(windows)]
6710 #[test]
6711 fn resolve_relative_to_root_returns_posix_rooted_path_unchanged_on_windows() {
6712 let path = std::path::Path::new(r"/ci/workspace/coverage-final.json");
6713 let resolved =
6714 resolve_relative_to_root(path, Some(std::path::Path::new(r"C:\work\my-app")));
6715 assert_eq!(resolved, path);
6716 }
6717
6718 #[test]
6719 fn resolve_relative_to_root_without_project_root_returns_relative_unchanged() {
6720 let resolved =
6721 resolve_relative_to_root(std::path::Path::new("coverage/coverage-final.json"), None);
6722 assert_eq!(
6723 resolved,
6724 std::path::PathBuf::from("coverage/coverage-final.json")
6725 );
6726 }
6727
6728 #[test]
6732 fn load_istanbul_coverage_resolves_relative_map_keys_against_project_root() {
6733 let temp = tempfile::TempDir::new().unwrap();
6734 let source_path = temp.path().join("src/index.ts");
6735 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6736 std::fs::write(&source_path, "export function f(){}").unwrap();
6737
6738 let coverage_path = temp.path().join("coverage-final.json");
6739 std::fs::write(
6740 &coverage_path,
6741 serde_json::to_string(&serde_json::json!({
6742 "src/index.ts": {
6743 "path": "src/index.ts",
6744 "statementMap": {},
6745 "fnMap": {
6746 "0": {
6747 "name": "f",
6748 "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6749 "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6750 }
6751 },
6752 "branchMap": {},
6753 "s": {},
6754 "f": { "0": 2 },
6755 "b": {}
6756 }
6757 }))
6758 .unwrap(),
6759 )
6760 .unwrap();
6761
6762 let coverage =
6763 load_istanbul_coverage(&coverage_path, None, Some(temp.path()), false).unwrap();
6764 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6765 let file_coverage = coverage.get(&canonical_source).unwrap();
6766
6767 assert_eq!(file_coverage.lookup("f", 1, 0), Some(100.0));
6768 }
6769
6770 #[test]
6771 fn load_istanbul_coverage_resolves_relative_path_against_project_root() {
6772 let temp = tempfile::TempDir::new().unwrap();
6773 let source_path = temp.path().join("src/index.ts");
6774 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6775 std::fs::write(&source_path, "export function f(){}").unwrap();
6776
6777 let coverage_path = temp.path().join("coverage/coverage-final.json");
6778 std::fs::create_dir_all(coverage_path.parent().unwrap()).unwrap();
6779 write_single_file_istanbul_fixture(
6780 &coverage_path,
6781 &source_path,
6782 &serde_json::json!({
6783 "0": {
6784 "name": "f",
6785 "decl": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } },
6786 "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 21 } }
6787 }
6788 }),
6789 &serde_json::json!({ "0": 1 }),
6790 );
6791
6792 let coverage = load_istanbul_coverage(
6793 std::path::Path::new("coverage/coverage-final.json"),
6794 None,
6795 Some(temp.path()),
6796 false,
6797 )
6798 .expect("relative path must resolve against project_root");
6799 assert!(
6800 !coverage.files.is_empty(),
6801 "expected coverage to load via project_root resolution, got {} files",
6802 coverage.files.len()
6803 );
6804 }
6805
6806 #[test]
6807 fn load_istanbul_coverage_falls_back_to_decl_line_for_missing_fn_line() {
6808 let temp = tempfile::TempDir::new().unwrap();
6809 let source_path = temp.path().join("src/service.ts");
6810 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6811 std::fs::write(&source_path, "export class DataService {}\n").unwrap();
6812
6813 let coverage_path = temp.path().join("coverage-final.json");
6814 write_single_file_istanbul_fixture(
6815 &coverage_path,
6816 &source_path,
6817 &serde_json::json!({
6818 "0": {
6819 "name": "(anonymous_0)",
6820 "decl": {
6821 "start": { "line": 5, "column": 2 },
6822 "end": { "line": 5, "column": 13 }
6823 },
6824 "loc": {
6825 "start": { "line": 5, "column": 14 },
6826 "end": { "line": 11, "column": 3 }
6827 }
6828 },
6829 "1": {
6830 "name": "(anonymous_1)",
6831 "decl": {
6832 "start": { "line": 20, "column": 14 },
6833 "end": { "line": 20, "column": 25 }
6834 },
6835 "loc": {
6836 "start": { "line": 20, "column": 28 },
6837 "end": { "line": 22, "column": 2 }
6838 }
6839 }
6840 }),
6841 &serde_json::json!({
6842 "0": 1,
6843 "1": 0
6844 }),
6845 );
6846
6847 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6848 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6849 let file_coverage = coverage.get(&canonical_source).unwrap();
6850
6851 assert_eq!(file_coverage.lookup("processData", 5, 0), Some(100.0));
6852 assert_eq!(file_coverage.lookup("handleSpecial", 20, 0), Some(0.0));
6853 }
6854
6855 #[test]
6856 fn load_istanbul_coverage_indexes_explicit_and_decl_lines() {
6857 let temp = tempfile::TempDir::new().unwrap();
6858 let source_path = temp.path().join("src/handler.ts");
6859 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6860 std::fs::write(&source_path, "export const handleClick = () => {}\n").unwrap();
6861
6862 let coverage_path = temp.path().join("coverage-final.json");
6863 write_single_file_istanbul_fixture(
6864 &coverage_path,
6865 &source_path,
6866 &serde_json::json!({
6867 "0": {
6868 "name": "handleClick",
6869 "line": 40,
6870 "decl": {
6871 "start": { "line": 22, "column": 13 },
6872 "end": { "line": 22, "column": 24 }
6873 },
6874 "loc": {
6875 "start": { "line": 40, "column": 27 },
6876 "end": { "line": 42, "column": 1 }
6877 }
6878 }
6879 }),
6880 &serde_json::json!({
6881 "0": 1
6882 }),
6883 );
6884
6885 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6886 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6887 let file_coverage = coverage.get(&canonical_source).unwrap();
6888
6889 assert_eq!(file_coverage.lookup("handleClick", 40, 0), Some(100.0));
6890 assert_eq!(file_coverage.lookup("handleClick", 22, 13), Some(100.0));
6891 }
6892
6893 #[test]
6894 fn load_istanbul_coverage_indexes_valid_body_start_alias() {
6895 let temp = tempfile::TempDir::new().unwrap();
6896 let source_path = temp.path().join("src/handler.ts");
6897 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6898 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
6899
6900 let coverage_path = temp.path().join("coverage-final.json");
6901 write_single_file_istanbul_fixture(
6902 &coverage_path,
6903 &source_path,
6904 &serde_json::json!({
6905 "0": {
6906 "name": "(anonymous_0)",
6907 "line": 8,
6908 "decl": {
6909 "start": { "line": 8, "column": 14 },
6910 "end": { "line": 8, "column": 25 }
6911 },
6912 "loc": {
6913 "start": { "line": 20, "column": 6 },
6914 "end": { "line": 22, "column": 1 }
6915 }
6916 }
6917 }),
6918 &serde_json::json!({ "0": 1 }),
6919 );
6920
6921 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6922 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6923 let file_coverage = coverage.get(&canonical_source).unwrap();
6924
6925 assert_eq!(file_coverage.lookup("handler", 20, 6), Some(100.0));
6926
6927 let mut function = make_fn_complexity(4);
6928 function.name = "handler".to_string();
6929 function.line = 20;
6930 function.col = 6;
6931 let result = istanbul_crap_default(&[function], Some(file_coverage), false);
6932 assert_eq!(result.matched, 1);
6933 assert_eq!(result.total, 1);
6934 assert_eq!(
6935 result.per_function[0].coverage_source,
6936 fallow_output::CoverageSource::Istanbul
6937 );
6938 assert_eq!(result.per_function[0].coverage_pct, Some(100.0));
6939 }
6940
6941 #[test]
6946 fn a_context_named_generator_takes_its_own_anonymous_record() {
6947 let temp = tempfile::TempDir::new().unwrap();
6948 let source_path = temp.path().join("src/load.ts");
6949 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
6950 std::fs::write(
6951 &source_path,
6952 "export const load = (id: string) =>\n Effect.gen(function* () {\n if (id) {}\n });\n",
6953 )
6954 .unwrap();
6955
6956 let coverage_path = temp.path().join("coverage-final.json");
6957 write_single_file_istanbul_fixture(
6958 &coverage_path,
6959 &source_path,
6960 &serde_json::json!({
6961 "0": {
6962 "name": "load",
6963 "line": 1,
6964 "decl": { "start": { "line": 1, "column": 20 }, "end": { "line": 1, "column": 21 } },
6965 "loc": { "start": { "line": 1, "column": 20 }, "end": { "line": 4, "column": 4 } }
6966 },
6967 "1": {
6968 "name": "(anonymous_1)",
6969 "line": 2,
6970 "decl": { "start": { "line": 2, "column": 13 }, "end": { "line": 2, "column": 14 } },
6971 "loc": { "start": { "line": 2, "column": 13 }, "end": { "line": 4, "column": 3 } }
6972 }
6973 }),
6974 &serde_json::json!({ "0": 1, "1": 0 }),
6975 );
6976
6977 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
6978 let canonical_source = dunce::canonicalize(&source_path).unwrap();
6979 let file_coverage = coverage.get(&canonical_source).unwrap();
6980
6981 let mut generator = make_fn_complexity(2);
6982 generator.name = "load".to_string();
6983 generator.line = 2;
6984 generator.col = 13;
6985 assert_eq!(
6986 file_coverage.lookup_function(&generator),
6987 Some(100.0),
6988 "a declared name matches the wrapper record"
6989 );
6990 generator.context_named = Some(fallow_types::extract::AnonymousFunctionKind::Function);
6991 assert_eq!(file_coverage.lookup_function(&generator), Some(0.0));
6992 }
6993
6994 #[test]
7001 fn curried_arrows_one_per_line_each_take_their_own_record() {
7002 let temp = tempfile::TempDir::new().unwrap();
7003 let source_path = temp.path().join("src/adjust.ts");
7004 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7005 std::fs::write(
7006 &source_path,
7007 "export const adjust = (base: number) =>\n (factor: number) =>\n (offset: number) =>\n base * factor + offset;\n",
7008 )
7009 .unwrap();
7010
7011 let coverage_path = temp.path().join("coverage-final.json");
7012 write_single_file_istanbul_fixture(
7013 &coverage_path,
7014 &source_path,
7015 &serde_json::json!({
7016 "0": {
7017 "name": "(anonymous_0)",
7018 "line": 2,
7019 "decl": { "start": { "line": 1, "column": 22 }, "end": { "line": 1, "column": 23 } },
7020 "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 26 } }
7021 },
7022 "1": {
7023 "name": "(anonymous_1)",
7024 "line": 3,
7025 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7026 "loc": { "start": { "line": 3, "column": 2 }, "end": { "line": 4, "column": 26 } }
7027 },
7028 "2": {
7029 "name": "(anonymous_2)",
7030 "line": 4,
7031 "decl": { "start": { "line": 3, "column": 2 }, "end": { "line": 3, "column": 3 } },
7032 "loc": { "start": { "line": 4, "column": 4 }, "end": { "line": 4, "column": 26 } }
7033 }
7034 }),
7035 &serde_json::json!({ "0": 2, "1": 1, "2": 0 }),
7036 );
7037
7038 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7039 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7040 let file_coverage = coverage.get(&canonical_source).unwrap();
7041
7042 assert_eq!(file_coverage.lookup("adjust", 1, 22), Some(100.0));
7043 assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(100.0));
7044 assert_eq!(file_coverage.lookup("<arrow>", 3, 2), Some(0.0));
7046 }
7047
7048 #[test]
7055 fn a_default_value_keeps_its_own_record_inside_the_enclosing_signature() {
7056 let temp = tempfile::TempDir::new().unwrap();
7057 let source_path = temp.path().join("src/filter.ts");
7058 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7059 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7060
7061 let coverage_path = temp.path().join("coverage-final.json");
7062 write_single_file_istanbul_fixture(
7063 &coverage_path,
7064 &source_path,
7065 &serde_json::json!({
7066 "0": {
7067 "name": "(anonymous_0)",
7068 "line": 173,
7069 "decl": { "start": { "line": 169, "column": 2 }, "end": { "line": 170, "column": 3 } },
7070 "loc": { "start": { "line": 173, "column": 4 }, "end": { "line": 182, "column": 3 } }
7071 },
7072 "1": {
7073 "name": "(anonymous_1)",
7074 "line": 171,
7075 "decl": { "start": { "line": 171, "column": 21 }, "end": { "line": 171, "column": 67 } },
7076 "loc": { "start": { "line": 171, "column": 67 }, "end": { "line": 171, "column": 76 } }
7077 }
7078 }),
7079 &serde_json::json!({ "0": 46, "1": 0 }),
7080 );
7081
7082 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7083 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7084 let file_coverage = coverage.get(&canonical_source).unwrap();
7085
7086 assert_eq!(file_coverage.lookup("<arrow>", 171, 61), Some(0.0));
7089 }
7090
7091 #[test]
7098 fn header_span_abstains_when_another_function_is_declared_inside_it() {
7099 let temp = tempfile::TempDir::new().unwrap();
7100 let source_path = temp.path().join("src/chart.ts");
7101 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7102 std::fs::write(
7103 &source_path,
7104 "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",
7105 )
7106 .unwrap();
7107
7108 let coverage_path = temp.path().join("coverage-final.json");
7109 write_single_file_istanbul_fixture(
7110 &coverage_path,
7111 &source_path,
7112 &serde_json::json!({
7113 "0": {
7114 "name": "(anonymous_0)",
7115 "line": 10,
7116 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7117 "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
7118 },
7119 "1": {
7120 "name": "scale",
7121 "line": 7,
7122 "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 28 } },
7123 "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
7124 }
7125 }),
7126 &serde_json::json!({ "0": 3, "1": 0 }),
7127 );
7128
7129 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7130 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7131 let coverage = load_istanbul_coverage_for_sources(
7132 &coverage_path,
7133 None,
7134 Some(temp.path()),
7135 Some(&discovered_sources),
7136 false,
7137 )
7138 .unwrap();
7139 let file_coverage = coverage.get(&canonical_source).unwrap();
7140
7141 assert_eq!(file_coverage.lookup("<anonymous>", 6, 6), None);
7144 assert_eq!(file_coverage.lookup("scale", 5, 14), Some(0.0));
7146 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7147 }
7148
7149 #[test]
7153 fn same_named_function_in_signature_does_not_supply_member_coverage() {
7154 let temp = tempfile::TempDir::new().unwrap();
7155 let source_path = temp.path().join("src/chart.ts");
7156 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7157 std::fs::write(
7158 &source_path,
7159 "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",
7160 )
7161 .unwrap();
7162
7163 let coverage_path = temp.path().join("coverage-final.json");
7164 write_single_file_istanbul_fixture(
7165 &coverage_path,
7166 &source_path,
7167 &serde_json::json!({
7168 "0": {
7169 "name": "(anonymous_0)",
7170 "line": 10,
7171 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7172 "loc": { "start": { "line": 10, "column": 4 }, "end": { "line": 12, "column": 3 } }
7173 },
7174 "1": {
7175 "name": "render",
7176 "line": 7,
7177 "decl": { "start": { "line": 5, "column": 23 }, "end": { "line": 5, "column": 29 } },
7178 "loc": { "start": { "line": 7, "column": 6 }, "end": { "line": 9, "column": 5 } }
7179 }
7180 }),
7181 &serde_json::json!({ "0": 3, "1": 0 }),
7182 );
7183
7184 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7185 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7186 let file_coverage = coverage.get(&canonical_source).unwrap();
7187
7188 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7189 assert_eq!(file_coverage.lookup("render", 5, 23), Some(0.0));
7190 }
7191
7192 #[test]
7196 fn same_line_same_named_function_in_signature_does_not_supply_member_coverage() {
7197 let temp = tempfile::TempDir::new().unwrap();
7198 let source_path = temp.path().join("src/chart.ts");
7199 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7200 std::fs::write(
7201 &source_path,
7202 "export class Chart {\n @Watch(\"data\") render(x = function render() {}) {}\n}\n",
7203 )
7204 .unwrap();
7205
7206 let coverage_path = temp.path().join("coverage-final.json");
7207 write_single_file_istanbul_fixture(
7208 &coverage_path,
7209 &source_path,
7210 &serde_json::json!({
7211 "0": {
7212 "name": "(anonymous_0)",
7213 "line": 2,
7214 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7215 "loc": { "start": { "line": 2, "column": 52 }, "end": { "line": 2, "column": 54 } }
7216 },
7217 "1": {
7218 "name": "render",
7219 "line": 2,
7220 "decl": { "start": { "line": 2, "column": 39 }, "end": { "line": 2, "column": 45 } },
7221 "loc": { "start": { "line": 2, "column": 48 }, "end": { "line": 2, "column": 50 } }
7222 }
7223 }),
7224 &serde_json::json!({ "0": 3, "1": 0 }),
7225 );
7226
7227 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7228 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7229 let coverage = load_istanbul_coverage_for_sources(
7230 &coverage_path,
7231 None,
7232 Some(temp.path()),
7233 Some(&discovered_sources),
7234 false,
7235 )
7236 .unwrap();
7237 let file_coverage = coverage.get(&canonical_source).unwrap();
7238
7239 assert_eq!(file_coverage.lookup("render", 2, 23), Some(100.0));
7240 assert_eq!(file_coverage.lookup("render", 2, 29), Some(0.0));
7241 }
7242
7243 #[test]
7244 fn named_generator_alias_handles_trivia_and_utf16_columns() {
7245 let temp = tempfile::TempDir::new().unwrap();
7246 let source_path = temp.path().join("src/chart.ts");
7247 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7248 let source_line =
7249 " render(label = \"pi: π, mushroom: 🍄\", x = function/* gap */*render() {}) {}";
7250 std::fs::write(
7251 &source_path,
7252 format!("export class Chart {{\n{source_line}\n}}\n"),
7253 )
7254 .unwrap();
7255
7256 let utf16_column = |byte_column: usize| {
7257 u32::try_from(source_line[..byte_column].encode_utf16().count()).unwrap()
7258 };
7259 let outer_target_column = source_line.find("render(").unwrap() + "render".len();
7260 let syntax_column = source_line.find("function").unwrap();
7261 let name_column = source_line.find("*render").unwrap() + 1;
7262 let inner_body_column = source_line.find("{}").unwrap();
7263 let outer_body_column = source_line.rfind("{}").unwrap();
7264
7265 let coverage_path = temp.path().join("coverage-final.json");
7266 write_single_file_istanbul_fixture(
7267 &coverage_path,
7268 &source_path,
7269 &serde_json::json!({
7270 "0": {
7271 "name": "(anonymous_0)",
7272 "line": 2,
7273 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7274 "loc": {
7275 "start": { "line": 2, "column": utf16_column(outer_body_column) },
7276 "end": { "line": 2, "column": utf16_column(outer_body_column + 2) }
7277 }
7278 },
7279 "1": {
7280 "name": "render",
7281 "line": 2,
7282 "decl": {
7283 "start": { "line": 2, "column": utf16_column(name_column) },
7284 "end": { "line": 2, "column": utf16_column(name_column + "render".len()) }
7285 },
7286 "loc": {
7287 "start": { "line": 2, "column": utf16_column(inner_body_column) },
7288 "end": { "line": 2, "column": utf16_column(inner_body_column + 2) }
7289 }
7290 }
7291 }),
7292 &serde_json::json!({ "0": 3, "1": 0 }),
7293 );
7294
7295 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7296 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7297 let coverage = load_istanbul_coverage_for_sources(
7298 &coverage_path,
7299 None,
7300 Some(temp.path()),
7301 Some(&discovered_sources),
7302 false,
7303 )
7304 .unwrap();
7305 let file_coverage = coverage.get(&canonical_source).unwrap();
7306
7307 assert_eq!(
7308 file_coverage.lookup("render", 2, u32::try_from(outer_target_column).unwrap()),
7309 Some(100.0)
7310 );
7311 assert_eq!(
7312 file_coverage.lookup("render", 2, u32::try_from(syntax_column).unwrap()),
7313 Some(0.0)
7314 );
7315 }
7316
7317 #[test]
7318 fn utf16_index_is_sparse_and_rejects_surrogate_boundaries() {
7319 let ascii_prefix = "a".repeat(4_096);
7320 let source = format!("{ascii_prefix}🍄{}", "b".repeat(4_096));
7321 let index = IstanbulSourceIndex::new(&source, std::path::Path::new("minified.js"));
7322 let line_index = &index.non_ascii_lines[&0];
7323
7324 assert_eq!(line_index.checkpoints.len(), 1);
7325 assert_eq!(
7326 index.byte_position(1, 4_096),
7327 Some(IstanbulPosition::new(1, 4_096))
7328 );
7329 assert_eq!(index.byte_position(1, 4_097), None);
7330 assert_eq!(
7331 index.byte_position(1, 4_098),
7332 Some(IstanbulPosition::new(1, 4_100))
7333 );
7334 assert_eq!(
7335 index.byte_position(1, 8_194),
7336 Some(IstanbulPosition::new(1, 8_196))
7337 );
7338 }
7339
7340 #[test]
7341 fn effective_alias_normalizes_against_its_own_unicode_line() {
7342 let temp = tempfile::TempDir::new().unwrap();
7343 let source_path = temp.path().join("src/render.ts");
7344 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7345 std::fs::write(
7346 &source_path,
7347 "/*🍄*/ const placeholder = 0;\n/*π*/ function render() {}\n",
7348 )
7349 .unwrap();
7350
7351 let coverage_path = temp.path().join("coverage-final.json");
7352 write_single_file_istanbul_fixture(
7353 &coverage_path,
7354 &source_path,
7355 &serde_json::json!({
7356 "0": {
7357 "name": "render",
7358 "line": 1,
7359 "decl": { "start": { "line": 2, "column": 15 }, "end": { "line": 2, "column": 21 } },
7360 "loc": { "start": { "line": 2, "column": 24 }, "end": { "line": 2, "column": 26 } }
7361 }
7362 }),
7363 &serde_json::json!({ "0": 1 }),
7364 );
7365
7366 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7367 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7368 let coverage = load_istanbul_coverage_for_sources(
7369 &coverage_path,
7370 None,
7371 Some(temp.path()),
7372 Some(&discovered_sources),
7373 false,
7374 )
7375 .unwrap();
7376 let function = &coverage.get(&canonical_source).unwrap().functions[0];
7377
7378 assert!(
7379 function
7380 .aliases
7381 .iter()
7382 .any(|alias| { alias.position == IstanbulPosition::new(1, 17) && alias.primary })
7383 );
7384 assert!(
7385 !function
7386 .aliases
7387 .iter()
7388 .any(|alias| alias.position == IstanbulPosition::new(1, 16))
7389 );
7390 }
7391
7392 #[test]
7393 fn stale_utf16_coordinates_are_rejected_with_trusted_source() {
7394 let temp = tempfile::TempDir::new().unwrap();
7395 let source_path = temp.path().join("src/render.ts");
7396 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7397 std::fs::write(&source_path, "export function render() {}\n").unwrap();
7398
7399 let coverage_path = temp.path().join("coverage-final.json");
7400 write_single_file_istanbul_fixture(
7401 &coverage_path,
7402 &source_path,
7403 &serde_json::json!({
7404 "0": {
7405 "name": "render",
7406 "line": 1,
7407 "decl": { "start": { "line": 1, "column": 999 }, "end": { "line": 1, "column": 22 } },
7408 "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
7409 }
7410 }),
7411 &serde_json::json!({ "0": 1 }),
7412 );
7413
7414 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7415 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7416 let coverage = load_istanbul_coverage_for_sources(
7417 &coverage_path,
7418 None,
7419 Some(temp.path()),
7420 Some(&discovered_sources),
7421 false,
7422 )
7423 .unwrap();
7424
7425 assert_eq!(
7426 coverage
7427 .get(&canonical_source)
7428 .unwrap()
7429 .lookup("render", 1, 7),
7430 None
7431 );
7432 }
7433
7434 #[test]
7435 fn invalid_optional_coordinates_preserve_valid_declaration_attribution() {
7436 let temp = tempfile::TempDir::new().unwrap();
7437 let source_path = temp.path().join("src/render.ts");
7438 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7439 std::fs::write(&source_path, "export function render() {}\n").unwrap();
7440
7441 let coverage_path = temp.path().join("coverage-final.json");
7442 write_single_file_istanbul_fixture(
7443 &coverage_path,
7444 &source_path,
7445 &serde_json::json!({
7446 "0": {
7447 "name": "render",
7448 "line": 99,
7449 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 99, "column": 999 } },
7450 "loc": { "start": { "line": 1, "column": 999 }, "end": { "line": 99, "column": 999 } }
7451 }
7452 }),
7453 &serde_json::json!({ "0": 1 }),
7454 );
7455
7456 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7457 let discovered_sources = rustc_hash::FxHashSet::from_iter([canonical_source.clone()]);
7458 let coverage = load_istanbul_coverage_for_sources(
7459 &coverage_path,
7460 None,
7461 Some(temp.path()),
7462 Some(&discovered_sources),
7463 false,
7464 )
7465 .unwrap();
7466 let file_coverage = coverage.get(&canonical_source).unwrap();
7467 let function = &file_coverage.functions[0];
7468
7469 assert_eq!(file_coverage.lookup("render", 1, 16), Some(100.0));
7470 assert!(function.body_span.is_none());
7471 assert!(function.header_span.is_none());
7472 assert!(
7473 !function
7474 .aliases
7475 .iter()
7476 .any(|alias| { alias.position.line == 99 || alias.position.col == 999 })
7477 );
7478 }
7479
7480 #[test]
7481 fn malformed_source_does_not_supply_named_function_provenance() {
7482 assert!(
7483 !IstanbulSourceIndex::new(
7484 "export function render() {}",
7485 std::path::Path::new("valid.ts"),
7486 )
7487 .named_function_starts
7488 .is_empty()
7489 );
7490 let index = IstanbulSourceIndex::new(
7491 "export function render() {} const broken = ;",
7492 std::path::Path::new("broken.ts"),
7493 );
7494
7495 assert!(index.named_function_starts.is_empty());
7496 }
7497
7498 #[test]
7499 fn javascript_with_jsx_uses_clean_jsx_provenance_parse() {
7500 let index = IstanbulSourceIndex::new(
7501 "export function render() { return <div />; }",
7502 std::path::Path::new("component.js"),
7503 );
7504
7505 assert!(!index.named_function_starts.is_empty());
7506 }
7507
7508 #[test]
7509 fn undiscovered_coverage_path_is_not_loaded_for_source_provenance() {
7510 let temp = tempfile::TempDir::new().unwrap();
7511 let source_path = temp.path().join("src/excluded.ts");
7512 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7513 std::fs::write(&source_path, "export function render() {}\n").unwrap();
7514
7515 let coverage_path = temp.path().join("coverage-final.json");
7516 write_single_file_istanbul_fixture(
7517 &coverage_path,
7518 &source_path,
7519 &serde_json::json!({
7520 "0": {
7521 "name": "render",
7522 "line": 1,
7523 "decl": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 22 } },
7524 "loc": { "start": { "line": 1, "column": 25 }, "end": { "line": 1, "column": 27 } }
7525 }
7526 }),
7527 &serde_json::json!({ "0": 1 }),
7528 );
7529
7530 let coverage = load_istanbul_coverage_for_sources(
7531 &coverage_path,
7532 None,
7533 Some(temp.path()),
7534 Some(&rustc_hash::FxHashSet::default()),
7535 false,
7536 )
7537 .unwrap();
7538 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7539 let function = &coverage.get(&canonical_source).unwrap().functions[0];
7540
7541 assert!(
7542 function
7543 .aliases
7544 .iter()
7545 .all(|alias| alias.position != IstanbulPosition::new(1, 7))
7546 );
7547 }
7548
7549 #[test]
7558 fn decorated_member_matches_its_istanbul_header_span() {
7559 let temp = tempfile::TempDir::new().unwrap();
7560 let source_path = temp.path().join("src/users.controller.ts");
7561 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7562 std::fs::write(
7563 &source_path,
7564 "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",
7565 )
7566 .unwrap();
7567
7568 let coverage_path = temp.path().join("coverage-final.json");
7569 write_single_file_istanbul_fixture(
7570 &coverage_path,
7571 &source_path,
7572 &serde_json::json!({
7573 "0": {
7574 "name": "(anonymous_0)",
7575 "line": 6,
7576 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7577 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7578 }
7579 }),
7580 &serde_json::json!({ "0": 3 }),
7581 );
7582
7583 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7584 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7585 let file_coverage = coverage.get(&canonical_source).unwrap();
7586
7587 assert_eq!(
7589 file_coverage.lookup("findOneWithProfile", 3, 26),
7590 Some(100.0)
7591 );
7592 }
7593
7594 #[test]
7600 fn established_alias_wins_over_the_signature_that_contains_it() {
7601 let temp = tempfile::TempDir::new().unwrap();
7602 let source_path = temp.path().join("src/chart.ts");
7603 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7604 std::fs::write(
7605 &source_path,
7606 "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",
7607 )
7608 .unwrap();
7609
7610 let coverage_path = temp.path().join("coverage-final.json");
7611 write_single_file_istanbul_fixture(
7612 &coverage_path,
7613 &source_path,
7614 &serde_json::json!({
7615 "0": {
7616 "name": "(anonymous_0)",
7617 "line": 6,
7618 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7619 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7620 },
7621 "1": {
7622 "name": "(anonymous_1)",
7623 "line": 5,
7624 "decl": { "start": { "line": 5, "column": 14 }, "end": { "line": 5, "column": 15 } },
7625 "loc": { "start": { "line": 5, "column": 31 }, "end": { "line": 5, "column": 38 } }
7626 }
7627 }),
7628 &serde_json::json!({ "0": 3, "1": 0 }),
7629 );
7630
7631 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7632 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7633 let file_coverage = coverage.get(&canonical_source).unwrap();
7634
7635 assert_eq!(file_coverage.lookup("<arrow>", 5, 14), Some(0.0));
7637 assert_eq!(file_coverage.lookup("render", 3, 8), Some(100.0));
7639 }
7640
7641 #[test]
7649 fn signature_holding_a_function_abstains_instead_of_crediting_it() {
7650 let temp = tempfile::TempDir::new().unwrap();
7651 let source_path = temp.path().join("src/users.controller.ts");
7652 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7653 std::fs::write(
7654 &source_path,
7655 "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",
7656 )
7657 .unwrap();
7658
7659 let coverage_path = temp.path().join("coverage-final.json");
7660 write_single_file_istanbul_fixture(
7661 &coverage_path,
7662 &source_path,
7663 &serde_json::json!({
7664 "0": {
7665 "name": "(anonymous_0)",
7666 "line": 6,
7667 "decl": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 3 } },
7668 "loc": { "start": { "line": 6, "column": 4 }, "end": { "line": 8, "column": 3 } }
7669 },
7670 "1": {
7671 "name": "(anonymous_1)",
7672 "line": 5,
7673 "decl": { "start": { "line": 5, "column": 16 }, "end": { "line": 5, "column": 17 } },
7674 "loc": { "start": { "line": 5, "column": 33 }, "end": { "line": 5, "column": 43 } }
7675 }
7676 }),
7677 &serde_json::json!({ "0": 3, "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("findOneWithProfile", 3, 26), None);
7687 assert_eq!(file_coverage.lookup("<arrow>", 5, 16), Some(0.0));
7689 }
7690
7691 #[test]
7692 fn anonymous_record_aliases_do_not_tie_with_their_own_identity() {
7693 let temp = tempfile::TempDir::new().unwrap();
7694 let source_path = temp.path().join("src/aliases.ts");
7695 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7696 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
7697
7698 let coverage_path = temp.path().join("coverage-final.json");
7699 write_single_file_istanbul_fixture(
7700 &coverage_path,
7701 &source_path,
7702 &serde_json::json!({
7703 "0": {
7704 "name": "(anonymous_0)",
7705 "line": 10,
7706 "decl": {
7707 "start": { "line": 10, "column": 8 },
7708 "end": { "line": 10, "column": 9 }
7709 },
7710 "loc": {
7711 "start": { "line": 12, "column": 8 },
7712 "end": { "line": 13, "column": 1 }
7713 }
7714 }
7715 }),
7716 &serde_json::json!({ "0": 1 }),
7717 );
7718
7719 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7720 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7721 let file_coverage = coverage.get(&canonical_source).unwrap();
7722
7723 assert_eq!(file_coverage.lookup("handler", 11, 8), Some(100.0));
7724 }
7725
7726 #[test]
7731 fn curried_arrow_one_liner_resolves_both_arrows() {
7732 let temp = tempfile::TempDir::new().unwrap();
7733 let source_path = temp.path().join("src/nested.ts");
7734 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7735 std::fs::write(&source_path, "export const nested = () => () => true;\n").unwrap();
7736
7737 let coverage_path = temp.path().join("coverage-final.json");
7738 write_single_file_istanbul_fixture(
7739 &coverage_path,
7740 &source_path,
7741 &serde_json::json!({
7742 "0": {
7743 "name": "(anonymous_0)",
7744 "line": 1,
7745 "decl": {
7746 "start": { "line": 1, "column": 22 },
7747 "end": { "line": 1, "column": 23 }
7748 },
7749 "loc": {
7750 "start": { "line": 1, "column": 28 },
7751 "end": { "line": 1, "column": 38 }
7752 }
7753 },
7754 "1": {
7755 "name": "(anonymous_1)",
7756 "line": 1,
7757 "decl": {
7758 "start": { "line": 1, "column": 28 },
7759 "end": { "line": 1, "column": 29 }
7760 },
7761 "loc": {
7762 "start": { "line": 1, "column": 34 },
7763 "end": { "line": 1, "column": 38 }
7764 }
7765 }
7766 }),
7767 &serde_json::json!({ "0": 1, "1": 0 }),
7768 );
7769
7770 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7771 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7772 let file_coverage = coverage.get(&canonical_source).unwrap();
7773
7774 assert_eq!(file_coverage.lookup("nested", 1, 22), Some(100.0));
7775 assert_eq!(file_coverage.lookup("<arrow>", 1, 28), Some(0.0));
7776 }
7777
7778 #[test]
7799 fn nested_function_statements_do_not_lower_the_outer_function() {
7800 let temp = tempfile::TempDir::new().unwrap();
7801 let source_path = temp.path().join("src/nested.js");
7802 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7803 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7804
7805 let coverage_path = temp.path().join("coverage-final.json");
7806 write_single_file_istanbul_fixture_with_statements(
7807 &coverage_path,
7808 &source_path,
7809 &nested_return_fn_map(),
7810 &serde_json::json!({ "0": 1, "1": 0 }),
7811 &nested_return_statement_map(),
7812 &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 1 }),
7813 );
7814
7815 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7816 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7817 let file_coverage = coverage.get(&canonical_source).unwrap();
7818
7819 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7820 assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7821 }
7822
7823 #[test]
7827 fn module_scope_statements_belong_to_no_function() {
7828 let temp = tempfile::TempDir::new().unwrap();
7829 let source_path = temp.path().join("src/nested.js");
7830 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7831 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7832
7833 let coverage_path = temp.path().join("coverage-final.json");
7834 write_single_file_istanbul_fixture_with_statements(
7835 &coverage_path,
7836 &source_path,
7837 &nested_return_fn_map(),
7838 &serde_json::json!({ "0": 1, "1": 0 }),
7839 &nested_return_statement_map(),
7840 &serde_json::json!({ "0": 1, "1": 0, "2": 0, "3": 0, "4": 0 }),
7841 );
7842
7843 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7844 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7845 let file_coverage = coverage.get(&canonical_source).unwrap();
7846
7847 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7848 assert_eq!(file_coverage.lookup("inner", 2, 18), Some(0.0));
7849 }
7850
7851 #[test]
7869 fn an_outer_function_with_only_a_nested_body_falls_back_to_the_hit_count() {
7870 let temp = tempfile::TempDir::new().unwrap();
7871 let source_path = temp.path().join("src/decl-nested.js");
7872 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7873 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7874
7875 let coverage_path = temp.path().join("coverage-final.json");
7876 write_single_file_istanbul_fixture_with_statements(
7877 &coverage_path,
7878 &source_path,
7879 &serde_json::json!({
7880 "0": {
7881 "name": "outer",
7882 "line": 1,
7883 "decl": {
7884 "start": { "line": 1, "column": 9 },
7885 "end": { "line": 1, "column": 14 }
7886 },
7887 "loc": {
7888 "start": { "line": 1, "column": 17 },
7889 "end": { "line": 6, "column": 1 }
7890 }
7891 },
7892 "1": {
7893 "name": "inner",
7894 "line": 2,
7895 "decl": {
7896 "start": { "line": 2, "column": 11 },
7897 "end": { "line": 2, "column": 16 }
7898 },
7899 "loc": {
7900 "start": { "line": 2, "column": 23 },
7901 "end": { "line": 5, "column": 3 }
7902 }
7903 }
7904 }),
7905 &serde_json::json!({ "0": 1, "1": 0 }),
7906 &serde_json::json!({
7907 "0": {
7908 "start": { "line": 3, "column": 4 },
7909 "end": { "line": 3, "column": 27 }
7910 },
7911 "1": {
7912 "start": { "line": 3, "column": 14 },
7913 "end": { "line": 3, "column": 27 }
7914 },
7915 "2": {
7916 "start": { "line": 4, "column": 4 },
7917 "end": { "line": 4, "column": 16 }
7918 },
7919 "3": {
7920 "start": { "line": 8, "column": 0 },
7921 "end": { "line": 8, "column": 27 }
7922 }
7923 }),
7924 &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1 }),
7925 );
7926
7927 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
7928 let canonical_source = dunce::canonicalize(&source_path).unwrap();
7929 let file_coverage = coverage.get(&canonical_source).unwrap();
7930
7931 assert_eq!(file_coverage.lookup("outer", 1, 9), Some(100.0));
7932 assert_eq!(file_coverage.lookup("inner", 2, 11), Some(0.0));
7933 }
7934
7935 #[test]
7940 fn sibling_functions_keep_independent_statement_coverage() {
7941 let temp = tempfile::TempDir::new().unwrap();
7942 let source_path = temp.path().join("src/sibling.js");
7943 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
7944 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
7945
7946 let coverage_path = temp.path().join("coverage-final.json");
7947 write_single_file_istanbul_fixture_with_statements(
7948 &coverage_path,
7949 &source_path,
7950 &serde_json::json!({
7951 "0": {
7952 "name": "inner",
7953 "line": 1,
7954 "decl": {
7955 "start": { "line": 1, "column": 9 },
7956 "end": { "line": 1, "column": 14 }
7957 },
7958 "loc": {
7959 "start": { "line": 1, "column": 21 },
7960 "end": { "line": 4, "column": 1 }
7961 }
7962 },
7963 "1": {
7964 "name": "outer",
7965 "line": 6,
7966 "decl": {
7967 "start": { "line": 6, "column": 9 },
7968 "end": { "line": 6, "column": 14 }
7969 },
7970 "loc": {
7971 "start": { "line": 6, "column": 17 },
7972 "end": { "line": 8, "column": 1 }
7973 }
7974 }
7975 }),
7976 &serde_json::json!({ "0": 0, "1": 1 }),
7977 &serde_json::json!({
7978 "0": {
7979 "start": { "line": 2, "column": 2 },
7980 "end": { "line": 2, "column": 25 }
7981 },
7982 "1": {
7983 "start": { "line": 2, "column": 12 },
7984 "end": { "line": 2, "column": 25 }
7985 },
7986 "2": {
7987 "start": { "line": 3, "column": 2 },
7988 "end": { "line": 3, "column": 14 }
7989 },
7990 "3": {
7991 "start": { "line": 7, "column": 2 },
7992 "end": { "line": 7, "column": 15 }
7993 },
7994 "4": {
7995 "start": { "line": 10, "column": 0 },
7996 "end": { "line": 10, "column": 27 }
7997 }
7998 }),
7999 &serde_json::json!({ "0": 0, "1": 0, "2": 0, "3": 1, "4": 1 }),
8000 );
8001
8002 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8003 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8004 let file_coverage = coverage.get(&canonical_source).unwrap();
8005
8006 assert_eq!(file_coverage.lookup("outer", 6, 9), Some(100.0));
8007 assert_eq!(file_coverage.lookup("inner", 1, 9), Some(0.0));
8008 }
8009
8010 #[test]
8016 fn curried_arrow_statements_belong_to_the_innermost_arrow() {
8017 let temp = tempfile::TempDir::new().unwrap();
8018 let source_path = temp.path().join("src/curried.js");
8019 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8020 std::fs::write(&source_path, "// geometry fixture\n").unwrap();
8021
8022 let coverage_path = temp.path().join("coverage-final.json");
8023 write_single_file_istanbul_fixture_with_statements(
8024 &coverage_path,
8025 &source_path,
8026 &serde_json::json!({
8027 "0": {
8028 "name": "(anonymous_0)",
8029 "line": 1,
8030 "decl": {
8031 "start": { "line": 1, "column": 12 },
8032 "end": { "line": 1, "column": 13 }
8033 },
8034 "loc": {
8035 "start": { "line": 1, "column": 19 },
8036 "end": { "line": 1, "column": 31 }
8037 }
8038 },
8039 "1": {
8040 "name": "(anonymous_1)",
8041 "line": 1,
8042 "decl": {
8043 "start": { "line": 1, "column": 19 },
8044 "end": { "line": 1, "column": 20 }
8045 },
8046 "loc": {
8047 "start": { "line": 1, "column": 26 },
8048 "end": { "line": 1, "column": 31 }
8049 }
8050 }
8051 }),
8052 &serde_json::json!({ "0": 1, "1": 0 }),
8053 &serde_json::json!({
8054 "0": {
8055 "start": { "line": 1, "column": 12 },
8056 "end": { "line": 1, "column": 31 }
8057 },
8058 "1": {
8059 "start": { "line": 1, "column": 19 },
8060 "end": { "line": 1, "column": 31 }
8061 },
8062 "2": {
8063 "start": { "line": 1, "column": 26 },
8064 "end": { "line": 1, "column": 31 }
8065 },
8066 "3": {
8067 "start": { "line": 3, "column": 0 },
8068 "end": { "line": 3, "column": 25 }
8069 }
8070 }),
8071 &serde_json::json!({ "0": 1, "1": 1, "2": 0, "3": 1 }),
8072 );
8073
8074 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8075 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8076 let file_coverage = coverage.get(&canonical_source).unwrap();
8077
8078 assert_eq!(file_coverage.lookup("add", 1, 12), Some(100.0));
8079 assert_eq!(file_coverage.lookup("<arrow>", 1, 19), Some(0.0));
8080 }
8081
8082 fn nested_return_fn_map() -> serde_json::Value {
8086 serde_json::json!({
8087 "0": {
8088 "name": "outer",
8089 "line": 1,
8090 "decl": {
8091 "start": { "line": 1, "column": 9 },
8092 "end": { "line": 1, "column": 14 }
8093 },
8094 "loc": {
8095 "start": { "line": 1, "column": 17 },
8096 "end": { "line": 6, "column": 1 }
8097 }
8098 },
8099 "1": {
8100 "name": "inner",
8101 "line": 2,
8102 "decl": {
8103 "start": { "line": 2, "column": 18 },
8104 "end": { "line": 2, "column": 23 }
8105 },
8106 "loc": {
8107 "start": { "line": 2, "column": 30 },
8108 "end": { "line": 5, "column": 3 }
8109 }
8110 }
8111 })
8112 }
8113
8114 fn nested_return_statement_map() -> serde_json::Value {
8115 serde_json::json!({
8116 "0": {
8117 "start": { "line": 2, "column": 2 },
8118 "end": { "line": 5, "column": 4 }
8119 },
8120 "1": {
8121 "start": { "line": 3, "column": 4 },
8122 "end": { "line": 3, "column": 27 }
8123 },
8124 "2": {
8125 "start": { "line": 3, "column": 14 },
8126 "end": { "line": 3, "column": 27 }
8127 },
8128 "3": {
8129 "start": { "line": 4, "column": 4 },
8130 "end": { "line": 4, "column": 16 }
8131 },
8132 "4": {
8133 "start": { "line": 8, "column": 0 },
8134 "end": { "line": 8, "column": 27 }
8135 }
8136 })
8137 }
8138
8139 #[test]
8143 fn curried_arrow_multiline_hoc_resolves_both_arrows() {
8144 let temp = tempfile::TempDir::new().unwrap();
8145 let source_path = temp.path().join("src/with-auth.tsx");
8146 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8147 std::fs::write(
8148 &source_path,
8149 "export const withAuth = (Component) =>\n (props) => {\n return Component(props);\n };\n",
8150 )
8151 .unwrap();
8152
8153 let coverage_path = temp.path().join("coverage-final.json");
8154 write_single_file_istanbul_fixture(
8155 &coverage_path,
8156 &source_path,
8157 &serde_json::json!({
8158 "0": {
8159 "name": "(anonymous_0)",
8160 "line": 2,
8161 "decl": {
8162 "start": { "line": 1, "column": 24 },
8163 "end": { "line": 1, "column": 25 }
8164 },
8165 "loc": {
8166 "start": { "line": 2, "column": 2 },
8167 "end": { "line": 4, "column": 3 }
8168 }
8169 },
8170 "1": {
8171 "name": "(anonymous_1)",
8172 "line": 2,
8173 "decl": {
8174 "start": { "line": 2, "column": 2 },
8175 "end": { "line": 2, "column": 3 }
8176 },
8177 "loc": {
8178 "start": { "line": 2, "column": 13 },
8179 "end": { "line": 4, "column": 3 }
8180 }
8181 }
8182 }),
8183 &serde_json::json!({ "0": 1, "1": 0 }),
8184 );
8185
8186 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8187 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8188 let file_coverage = coverage.get(&canonical_source).unwrap();
8189
8190 assert_eq!(file_coverage.lookup("withAuth", 1, 24), Some(100.0));
8191 assert_eq!(file_coverage.lookup("<arrow>", 2, 2), Some(0.0));
8192 }
8193
8194 #[test]
8197 fn curried_arrow_depth_three_chain_resolves_every_arrow() {
8198 let temp = tempfile::TempDir::new().unwrap();
8199 let source_path = temp.path().join("src/logger.ts");
8200 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8201 std::fs::write(
8202 &source_path,
8203 "export const logger = (store) => (next) => (action) => {\n return next(action);\n};\n",
8204 )
8205 .unwrap();
8206
8207 let coverage_path = temp.path().join("coverage-final.json");
8208 write_single_file_istanbul_fixture(
8209 &coverage_path,
8210 &source_path,
8211 &serde_json::json!({
8212 "0": {
8213 "name": "(anonymous_0)",
8214 "line": 1,
8215 "decl": {
8216 "start": { "line": 1, "column": 22 },
8217 "end": { "line": 1, "column": 23 }
8218 },
8219 "loc": {
8220 "start": { "line": 1, "column": 33 },
8221 "end": { "line": 3, "column": 1 }
8222 }
8223 },
8224 "1": {
8225 "name": "(anonymous_1)",
8226 "line": 1,
8227 "decl": {
8228 "start": { "line": 1, "column": 33 },
8229 "end": { "line": 1, "column": 34 }
8230 },
8231 "loc": {
8232 "start": { "line": 1, "column": 43 },
8233 "end": { "line": 3, "column": 1 }
8234 }
8235 },
8236 "2": {
8237 "name": "(anonymous_2)",
8238 "line": 1,
8239 "decl": {
8240 "start": { "line": 1, "column": 43 },
8241 "end": { "line": 1, "column": 44 }
8242 },
8243 "loc": {
8244 "start": { "line": 1, "column": 55 },
8245 "end": { "line": 3, "column": 1 }
8246 }
8247 }
8248 }),
8249 &serde_json::json!({ "0": 0, "1": 1, "2": 0 }),
8250 );
8251
8252 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8253 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8254 let file_coverage = coverage.get(&canonical_source).unwrap();
8255
8256 assert_eq!(file_coverage.lookup("logger", 1, 22), Some(0.0));
8257 assert_eq!(file_coverage.lookup("<arrow>", 1, 33), Some(100.0));
8258 assert_eq!(file_coverage.lookup("<arrow>", 1, 43), Some(0.0));
8259 }
8260
8261 #[test]
8263 fn curried_class_property_arrow_resolves_both_arrows() {
8264 let temp = tempfile::TempDir::new().unwrap();
8265 let source_path = temp.path().join("src/store.ts");
8266 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8267 std::fs::write(
8268 &source_path,
8269 "export class Store {\n handle = (event) => (payload) => {\n return payload;\n };\n}\n",
8270 )
8271 .unwrap();
8272
8273 let coverage_path = temp.path().join("coverage-final.json");
8274 write_single_file_istanbul_fixture(
8275 &coverage_path,
8276 &source_path,
8277 &serde_json::json!({
8278 "0": {
8279 "name": "(anonymous_0)",
8280 "line": 2,
8281 "decl": {
8282 "start": { "line": 2, "column": 11 },
8283 "end": { "line": 2, "column": 12 }
8284 },
8285 "loc": {
8286 "start": { "line": 2, "column": 22 },
8287 "end": { "line": 4, "column": 3 }
8288 }
8289 },
8290 "1": {
8291 "name": "(anonymous_1)",
8292 "line": 2,
8293 "decl": {
8294 "start": { "line": 2, "column": 22 },
8295 "end": { "line": 2, "column": 23 }
8296 },
8297 "loc": {
8298 "start": { "line": 2, "column": 35 },
8299 "end": { "line": 4, "column": 3 }
8300 }
8301 }
8302 }),
8303 &serde_json::json!({ "0": 1, "1": 0 }),
8304 );
8305
8306 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8307 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8308 let file_coverage = coverage.get(&canonical_source).unwrap();
8309
8310 assert_eq!(file_coverage.lookup("handle", 2, 11), Some(100.0));
8311 assert_eq!(file_coverage.lookup("<arrow>", 2, 22), Some(0.0));
8312 }
8313
8314 #[test]
8318 fn anonymous_sibling_tie_outside_every_body_abstains() {
8319 let temp = tempfile::TempDir::new().unwrap();
8320 let source_path = temp.path().join("src/handlers.ts");
8321 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8322 std::fs::write(
8323 &source_path,
8324 "export const handlers = {\n a: () => true,\n\n b: () => false,\n};\n",
8325 )
8326 .unwrap();
8327
8328 let coverage_path = temp.path().join("coverage-final.json");
8329 write_single_file_istanbul_fixture(
8330 &coverage_path,
8331 &source_path,
8332 &serde_json::json!({
8333 "0": {
8334 "name": "(anonymous_0)",
8335 "line": 2,
8336 "decl": {
8337 "start": { "line": 2, "column": 5 },
8338 "end": { "line": 2, "column": 6 }
8339 },
8340 "loc": {
8341 "start": { "line": 2, "column": 11 },
8342 "end": { "line": 2, "column": 15 }
8343 }
8344 },
8345 "1": {
8346 "name": "(anonymous_1)",
8347 "line": 4,
8348 "decl": {
8349 "start": { "line": 4, "column": 5 },
8350 "end": { "line": 4, "column": 6 }
8351 },
8352 "loc": {
8353 "start": { "line": 4, "column": 11 },
8354 "end": { "line": 4, "column": 16 }
8355 }
8356 }
8357 }),
8358 &serde_json::json!({ "0": 1, "1": 0 }),
8359 );
8360
8361 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8362 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8363 let file_coverage = coverage.get(&canonical_source).unwrap();
8364
8365 assert_eq!(file_coverage.lookup("a", 2, 5), Some(100.0));
8366 assert_eq!(file_coverage.lookup("b", 4, 5), Some(0.0));
8367 assert!(file_coverage.lookup("<arrow>", 3, 5).is_none());
8368 }
8369
8370 #[test]
8376 fn anonymous_tie_selects_unique_strictly_innermost_containing_span() {
8377 let temp = tempfile::TempDir::new().unwrap();
8378 let source_path = temp.path().join("src/nested.ts");
8379 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8380 std::fs::write(
8381 &source_path,
8382 "export const o = function () { const f = () => { return 1;\n }; return f; };\n",
8383 )
8384 .unwrap();
8385
8386 let coverage_path = temp.path().join("coverage-final.json");
8387 write_single_file_istanbul_fixture(
8388 &coverage_path,
8389 &source_path,
8390 &serde_json::json!({
8391 "0": {
8392 "name": "(anonymous_0)",
8393 "line": 1,
8394 "decl": {
8395 "start": { "line": 1, "column": 17 },
8396 "end": { "line": 1, "column": 18 }
8397 },
8398 "loc": {
8399 "start": { "line": 1, "column": 29 },
8400 "end": { "line": 2, "column": 53 }
8401 }
8402 },
8403 "1": {
8404 "name": "(anonymous_1)",
8405 "line": 1,
8406 "decl": {
8407 "start": { "line": 1, "column": 41 },
8408 "end": { "line": 1, "column": 42 }
8409 },
8410 "loc": {
8411 "start": { "line": 1, "column": 47 },
8412 "end": { "line": 2, "column": 40 }
8413 }
8414 }
8415 }),
8416 &serde_json::json!({ "0": 1, "1": 0 }),
8417 );
8418
8419 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8420 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8421 let file_coverage = coverage.get(&canonical_source).unwrap();
8422
8423 assert_eq!(file_coverage.lookup("<arrow>", 2, 35), Some(0.0));
8424 }
8425
8426 #[test]
8429 fn anonymous_tie_rejects_incomparable_containing_spans() {
8430 let file_coverage = IstanbulFileCoverage::new(
8431 vec![
8432 IstanbulFunctionCoverage {
8433 name: "(anonymous_0)".to_string(),
8434 coverage_pct: 100.0,
8435 aliases: vec![primary_alias(10, 8), secondary_alias(10, 14)],
8436 decl_start: IstanbulPosition::new(10, 8),
8437 header_holds_other_fn: false,
8438 header_span: None,
8439 body_span: Some(body_span((10, 14), (12, 30))),
8440 },
8441 IstanbulFunctionCoverage {
8442 name: "(anonymous_1)".to_string(),
8443 coverage_pct: 0.0,
8444 aliases: vec![primary_alias(10, 20), secondary_alias(11, 0)],
8445 decl_start: IstanbulPosition::new(10, 20),
8446 header_holds_other_fn: false,
8447 header_span: None,
8448 body_span: Some(body_span((11, 0), (14, 0))),
8449 },
8450 ],
8451 false,
8452 );
8453
8454 assert!(file_coverage.lookup("<arrow>", 12, 17).is_none());
8455 }
8456
8457 #[test]
8462 fn anonymous_shared_primary_alias_rejects_even_nested_spans() {
8463 let file_coverage = IstanbulFileCoverage::new(
8464 vec![
8465 IstanbulFunctionCoverage {
8466 name: "(anonymous_0)".to_string(),
8467 coverage_pct: 100.0,
8468 aliases: vec![primary_alias(4, 11), primary_alias(1, 11)],
8469 decl_start: IstanbulPosition::new(4, 11),
8470 header_holds_other_fn: false,
8471 header_span: None,
8472 body_span: Some(body_span((4, 11), (4, 23))),
8473 },
8474 IstanbulFunctionCoverage {
8475 name: "(anonymous_1)".to_string(),
8476 coverage_pct: 0.0,
8477 aliases: vec![primary_alias(4, 11), secondary_alias(4, 18)],
8478 decl_start: IstanbulPosition::new(4, 11),
8479 header_holds_other_fn: false,
8480 header_span: None,
8481 body_span: Some(body_span((4, 18), (4, 23))),
8482 },
8483 ],
8484 false,
8485 );
8486
8487 assert!(file_coverage.lookup("<arrow>", 4, 11).is_none());
8488 assert_eq!(file_coverage.lookup("aa", 1, 11), Some(100.0));
8489 }
8490
8491 #[test]
8492 fn colliding_anonymous_alias_uses_unique_safe_header_span() {
8493 let file_coverage = IstanbulFileCoverage::new(
8494 vec![
8495 IstanbulFunctionCoverage {
8496 name: "(anonymous_0)".to_string(),
8497 coverage_pct: 100.0,
8498 aliases: vec![primary_alias(1, 0), secondary_alias(3, 4)],
8499 decl_start: IstanbulPosition::new(1, 0),
8500 header_holds_other_fn: false,
8501 header_span: Some(body_span((1, 0), (5, 0))),
8502 body_span: Some(body_span((5, 0), (8, 0))),
8503 },
8504 IstanbulFunctionCoverage {
8505 name: "(anonymous_1)".to_string(),
8506 coverage_pct: 0.0,
8507 aliases: vec![primary_alias(10, 0), secondary_alias(3, 4)],
8508 decl_start: IstanbulPosition::new(10, 0),
8509 header_holds_other_fn: false,
8510 header_span: None,
8511 body_span: Some(body_span((10, 0), (12, 0))),
8512 },
8513 ],
8514 false,
8515 );
8516
8517 assert_eq!(file_coverage.lookup("<arrow>", 3, 4), Some(100.0));
8518 }
8519
8520 #[test]
8523 fn colliding_secondary_aliases_abstain_at_shared_position() {
8524 let file_coverage = IstanbulFileCoverage::new(
8525 vec![
8526 IstanbulFunctionCoverage {
8527 name: "(anonymous_0)".to_string(),
8528 coverage_pct: 100.0,
8529 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8530 decl_start: IstanbulPosition::new(10, 0),
8531 header_holds_other_fn: false,
8532 header_span: None,
8533 body_span: Some(body_span((12, 4), (20, 0))),
8534 },
8535 IstanbulFunctionCoverage {
8536 name: "(anonymous_1)".to_string(),
8537 coverage_pct: 0.0,
8538 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8539 decl_start: IstanbulPosition::new(11, 0),
8540 header_holds_other_fn: false,
8541 header_span: None,
8542 body_span: Some(body_span((12, 4), (18, 0))),
8543 },
8544 ],
8545 false,
8546 );
8547
8548 assert_eq!(file_coverage.lookup("first", 10, 0), Some(100.0));
8549 assert_eq!(file_coverage.lookup("second", 11, 0), Some(0.0));
8550 assert!(file_coverage.lookup("<arrow>", 12, 4).is_none());
8551 }
8552
8553 #[test]
8554 fn colliding_named_secondary_aliases_abstain_at_shared_position() {
8555 let file_coverage = IstanbulFileCoverage::new(
8556 vec![
8557 IstanbulFunctionCoverage {
8558 name: "handler".to_string(),
8559 coverage_pct: 100.0,
8560 aliases: vec![primary_alias(10, 0), secondary_alias(12, 4)],
8561 decl_start: IstanbulPosition::new(10, 0),
8562 header_holds_other_fn: false,
8563 header_span: None,
8564 body_span: Some(body_span((12, 4), (20, 0))),
8565 },
8566 IstanbulFunctionCoverage {
8567 name: "handler".to_string(),
8568 coverage_pct: 0.0,
8569 aliases: vec![primary_alias(11, 0), secondary_alias(12, 4)],
8570 decl_start: IstanbulPosition::new(11, 0),
8571 header_holds_other_fn: false,
8572 header_span: None,
8573 body_span: Some(body_span((12, 4), (18, 0))),
8574 },
8575 ],
8576 false,
8577 );
8578
8579 assert_eq!(file_coverage.lookup("handler", 10, 0), Some(100.0));
8580 assert_eq!(file_coverage.lookup("handler", 11, 0), Some(0.0));
8581 assert!(file_coverage.lookup("handler", 12, 4).is_none());
8582 }
8583
8584 #[test]
8585 fn invalid_body_location_does_not_create_an_alias() {
8586 let temp = tempfile::TempDir::new().unwrap();
8587 let source_path = temp.path().join("src/invalid-location.ts");
8588 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8589 std::fs::write(&source_path, "export const handler = () => true;\n").unwrap();
8590
8591 let coverage_path = temp.path().join("coverage-final.json");
8592 write_single_file_istanbul_fixture(
8593 &coverage_path,
8594 &source_path,
8595 &serde_json::json!({
8596 "0": {
8597 "name": "(anonymous_0)",
8598 "line": 8,
8599 "decl": {
8600 "start": { "line": 8, "column": 14 },
8601 "end": { "line": 8, "column": 25 }
8602 },
8603 "loc": {
8604 "start": { "line": 22, "column": 1 },
8605 "end": { "line": 20, "column": 6 }
8606 }
8607 }
8608 }),
8609 &serde_json::json!({ "0": 1 }),
8610 );
8611
8612 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8613 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8614 let file_coverage = coverage.get(&canonical_source).unwrap();
8615
8616 assert!(file_coverage.lookup("handler", 22, 1).is_none());
8617 }
8618
8619 #[test]
8620 fn load_istanbul_coverage_matches_multiline_async_arrow_decl_alias() {
8621 let temp = tempfile::TempDir::new().unwrap();
8622 let source_path = temp.path().join("src/actor.ts");
8623 std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
8624 std::fs::write(
8625 &source_path,
8626 "export const elementsFrom = async (\n locator: AnyLocator,\n options?: { missingAsEmpty?: boolean },\n): Promise<HTMLElement[]> => {\n return [];\n};\n",
8627 )
8628 .unwrap();
8629
8630 let coverage_path = temp.path().join("coverage-final.json");
8631 write_single_file_istanbul_fixture(
8632 &coverage_path,
8633 &source_path,
8634 &serde_json::json!({
8635 "0": {
8636 "name": "(anonymous_0)",
8637 "line": 4,
8638 "decl": {
8639 "start": { "line": 1, "column": 28 },
8640 "end": { "line": 4, "column": 26 }
8641 },
8642 "loc": {
8643 "start": { "line": 4, "column": 27 },
8644 "end": { "line": 6, "column": 1 }
8645 }
8646 }
8647 }),
8648 &serde_json::json!({
8649 "0": 642
8650 }),
8651 );
8652
8653 let coverage = load_istanbul_coverage(&coverage_path, None, None, false).unwrap();
8654 let canonical_source = dunce::canonicalize(&source_path).unwrap();
8655 let file_coverage = coverage.get(&canonical_source).unwrap();
8656
8657 assert_eq!(file_coverage.lookup("elementsFrom", 1, 28), Some(100.0));
8658 }
8659
8660 #[test]
8661 fn istanbul_lookup_exact_match() {
8662 let mut functions = rustc_hash::FxHashMap::default();
8663 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8664 let fc = test_istanbul_file_coverage(functions, false);
8665 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 85.0).abs() < f64::EPSILON);
8666 }
8667
8668 #[test]
8669 fn istanbul_lookup_fuzzy_match_within_offset() {
8670 let mut functions = rustc_hash::FxHashMap::default();
8671 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8672 let fc = test_istanbul_file_coverage(functions, false);
8673 assert!((fc.lookup("handleClick", 11, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8674 assert!((fc.lookup("handleClick", 12, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8675 }
8676
8677 #[test]
8678 fn istanbul_lookup_fuzzy_match_outside_offset() {
8679 let mut functions = rustc_hash::FxHashMap::default();
8680 functions.insert(("handleClick".to_string(), 10, 0), 72.0);
8681 let fc = test_istanbul_file_coverage(functions, false);
8682 assert!(fc.lookup("handleClick", 13, 0).is_none());
8683 }
8684
8685 #[test]
8686 fn istanbul_lookup_relocated_matches_unique_name_at_any_distance() {
8687 let mut functions = rustc_hash::FxHashMap::default();
8688 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8689 let fc = test_istanbul_file_coverage(functions, true);
8690 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8691 }
8692
8693 #[test]
8694 fn istanbul_lookup_relocated_accepts_declaration_alias_pair() {
8695 let mut functions = rustc_hash::FxHashMap::default();
8696 functions.insert(("handleClick".to_string(), 29, 16), 72.0);
8697 functions.insert(("handleClick".to_string(), 29, 0), 72.0);
8698 let fc = test_istanbul_file_coverage(functions, true);
8699 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8700 }
8701
8702 #[test]
8703 fn istanbul_lookup_relocated_bails_on_disagreeing_same_name_entries() {
8704 let mut functions = rustc_hash::FxHashMap::default();
8705 functions.insert(("render".to_string(), 29, 0), 72.0);
8706 functions.insert(("render".to_string(), 80, 0), 10.0);
8707 let fc = test_istanbul_file_coverage(functions, true);
8708 assert!(fc.lookup("render", 10, 0).is_none());
8709 }
8710
8711 #[test]
8712 fn istanbul_lookup_relocated_prefers_bounded_fuzzy_match() {
8713 let mut functions = rustc_hash::FxHashMap::default();
8714 functions.insert(("render".to_string(), 11, 0), 72.0);
8715 functions.insert(("render".to_string(), 80, 0), 10.0);
8716 let fc = test_istanbul_file_coverage(functions, true);
8717 assert!((fc.lookup("render", 10, 0).unwrap() - 72.0).abs() < f64::EPSILON);
8718 }
8719
8720 #[test]
8721 fn istanbul_lookup_name_mismatch() {
8722 let mut functions = rustc_hash::FxHashMap::default();
8723 functions.insert(("handleClick".to_string(), 10, 0), 85.0);
8724 let fc = test_istanbul_file_coverage(functions, false);
8725 assert!(fc.lookup("handleSubmit", 10, 0).is_none());
8726 }
8727
8728 #[test]
8729 fn istanbul_lookup_empty() {
8730 let fc = test_istanbul_file_coverage(rustc_hash::FxHashMap::default(), false);
8731 assert!(fc.lookup("anything", 1, 0).is_none());
8732 }
8733
8734 #[test]
8735 fn istanbul_lookup_fuzzy_picks_closest() {
8736 let mut functions = rustc_hash::FxHashMap::default();
8737 functions.insert(("render".to_string(), 8, 0), 60.0);
8738 functions.insert(("render".to_string(), 12, 0), 90.0);
8739 let fc = test_istanbul_file_coverage(functions, false);
8740 let result = fc.lookup("render", 10, 0);
8741 assert!(result.is_some());
8742 let pct = result.unwrap();
8743 assert!((pct - 60.0).abs() < f64::EPSILON || (pct - 90.0).abs() < f64::EPSILON);
8744 }
8745
8746 #[test]
8747 fn istanbul_lookup_anonymous_fallback_single_candidate() {
8748 let mut functions = rustc_hash::FxHashMap::default();
8749 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8750 let fc = test_istanbul_file_coverage(functions, false);
8751 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8752 assert!((fc.lookup("myHandler", 30, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8753 }
8754
8755 #[test]
8756 fn istanbul_lookup_anonymous_fallback_rejects_nearby_far_column() {
8757 let mut functions = rustc_hash::FxHashMap::default();
8758 functions.insert(("(anonymous_0)".to_string(), 4, 28), 75.0);
8759 let fc = test_istanbul_file_coverage(functions, false);
8760
8761 assert!(fc.lookup("declaredHelper", 3, 0).is_none());
8762 }
8763
8764 #[test]
8765 fn istanbul_lookup_anonymous_fallback_picks_closest_when_lines_differ() {
8766 let mut functions = rustc_hash::FxHashMap::default();
8767 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8768 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8769 let fc = test_istanbul_file_coverage(functions, false);
8770 assert!((fc.lookup("myHandler", 28, 0).unwrap() - 75.0).abs() < f64::EPSILON);
8771 }
8772
8773 #[test]
8774 fn istanbul_lookup_anonymous_fallback_picks_closest_by_col_on_same_line() {
8775 let mut functions = rustc_hash::FxHashMap::default();
8776 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);
8779 assert!((fc.lookup("<arrow>", 1, 43).unwrap() - 10.0).abs() < f64::EPSILON);
8780 assert!((fc.lookup("<arrow>", 1, 23).unwrap() - 90.0).abs() < f64::EPSILON);
8781 }
8782
8783 #[test]
8784 fn istanbul_lookup_anonymous_fallback_bails_only_on_true_tie() {
8785 let mut functions = rustc_hash::FxHashMap::default();
8786 functions.insert(("(anonymous_0)".to_string(), 27, 0), 75.0);
8787 functions.insert(("(anonymous_1)".to_string(), 29, 0), 50.0);
8788 let fc = test_istanbul_file_coverage(functions, false);
8789 assert!(fc.lookup("myHandler", 28, 0).is_none());
8790 }
8791
8792 #[test]
8793 fn istanbul_lookup_anonymous_fallback_outside_offset() {
8794 let mut functions = rustc_hash::FxHashMap::default();
8795 functions.insert(("(anonymous_0)".to_string(), 28, 0), 75.0);
8796 let fc = test_istanbul_file_coverage(functions, false);
8797 assert!(fc.lookup("myHandler", 31, 0).is_none());
8798 }
8799
8800 #[test]
8801 fn istanbul_lookup_named_match_beats_nearby_anonymous() {
8802 let mut functions = rustc_hash::FxHashMap::default();
8803 functions.insert(("handleClick".to_string(), 10, 0), 90.0);
8804 functions.insert(("(anonymous_7)".to_string(), 11, 0), 10.0);
8805 let fc = test_istanbul_file_coverage(functions, false);
8806 assert!((fc.lookup("handleClick", 10, 0).unwrap() - 90.0).abs() < f64::EPSILON);
8807 }
8808
8809 #[test]
8810 fn build_test_refs_empty() {
8811 let exports: Vec<fallow_graph::graph::ExportSymbol> = vec![];
8812 let graph = fallow_graph::graph::ModuleGraph::build(&[], &[], &[]);
8813 let refs = build_test_referenced_exports(
8814 crate::discover::FileId(0),
8815 &exports,
8816 StaticTestCoverage::new(&graph),
8817 );
8818 assert!(refs.is_empty());
8819 }
8820
8821 #[test]
8822 fn istanbul_crap_empty_complexity() {
8823 let result = istanbul_crap_default(&[], None, false);
8824 assert!((result.max_crap).abs() < f64::EPSILON);
8825 assert_eq!(result.signals.above, 0);
8826 assert_eq!(result.matched, 0);
8827 assert_eq!(result.total, 0);
8828 }
8829
8830 #[test]
8831 fn istanbul_crap_match_statistics() {
8832 let funcs = vec![make_fn_complexity(5), {
8833 let mut f = make_fn_complexity(3);
8834 f.name = "other_fn".into();
8835 f.line = 10;
8836 f
8837 }];
8838 let mut functions = rustc_hash::FxHashMap::default();
8839 functions.insert(("test_fn".to_string(), 1, 0), 80.0);
8840 let file_cov = test_istanbul_file_coverage(functions, false);
8841 let result = istanbul_crap_default(&funcs, Some(&file_cov), true);
8842 assert_eq!(result.matched, 1);
8843 assert_eq!(result.total, 2);
8844 }
8845
8846 #[test]
8847 fn estimated_crap_multiple_functions_mixed_coverage() {
8848 let funcs = vec![
8849 make_fn_complexity(10), {
8851 let mut f = make_fn_complexity(3);
8852 f.name = "helper".into();
8853 f.line = 20;
8854 f
8855 },
8856 ];
8857 let mut refs = rustc_hash::FxHashSet::default();
8858 refs.insert("test_fn".to_string());
8859 let result = estimated_crap_default(
8860 &funcs,
8861 &refs,
8862 true,
8863 fallow_output::CoverageSource::Estimated,
8864 );
8865 let (max, above) = (result.max_crap, result.signals.above);
8866 assert!(max > 10.0);
8867 assert_eq!(above, 0);
8868 }
8869}