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